biaws 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -131,7 +131,9 @@ biaws workspace skills status
131
131
 
132
132
  O Codex usa `.codex/config.toml` e `.agents/skills`. O Claude Code usa
133
133
  `.mcp.json` e `.claude/skills`. Configurações de terceiros são preservadas e
134
- nenhuma credencial é gravada no projeto.
134
+ nenhuma credencial é gravada no projeto. O MCP é configurado como
135
+ `npx --yes biaws-mcp@<versão-fixada>`, sem exigir um checkout local da
136
+ plataforma.
135
137
 
136
138
  ## Monitoramento
137
139
 
@@ -171,6 +173,7 @@ biaws admin instance list
171
173
  biaws admin instance status local
172
174
  biaws admin instance start local
173
175
  biaws admin instance stop local
176
+ biaws admin instance update local
174
177
  biaws admin instance backup local
175
178
  ```
176
179
 
@@ -10,7 +10,7 @@ biaws
10
10
  │ ├── install
11
11
  │ ├── doctor
12
12
  │ ├── config
13
- │ ├── instance setup|list|show|status|start|stop|backup|restore|remove
13
+ │ ├── instance setup|list|show|status|start|stop|update|backup|restore|remove
14
14
  │ └── monitoring build|validate|start|stop|status|logs|provision
15
15
  ├── config
16
16
  │ ├── init|login|show|set|unset|doctor
@@ -35,6 +35,46 @@ biaws admin instance stop local
35
35
  `status` invocam Docker Compose sem shell e com projeto, arquivo Compose e env
36
36
  da instância resolvidos explicitamente.
37
37
 
38
+ ## Atualização
39
+
40
+ Depois de atualizar o checkout ou instalar uma nova release, reconstrua API,
41
+ UI e os demais serviços com:
42
+
43
+ ```bash
44
+ biaws admin instance update local
45
+ ```
46
+
47
+ Antes de alterar a instância, consulte a versão efetivamente implantada e a
48
+ versão disponível no checkout:
49
+
50
+ ```bash
51
+ biaws admin instance update local --check
52
+ biaws admin instance update local --check --json
53
+ ```
54
+
55
+ O resultado informa `currentVersion`, `newVersion` e `updateRequired`.
56
+ Instâncias criadas antes desse mecanismo começam com `currentVersion` igual a
57
+ `unknown` e são consideradas pendentes. Se as versões forem iguais, o comando
58
+ não cria backup nem reconstrói containers; use `--force` para repetir o deploy.
59
+
60
+ Por padrão, o comando valida o arquivo Compose, solicita uma senha, cria um
61
+ backup completo e então executa `up -d --build --wait`. Em automação, use um
62
+ arquivo privado para a senha:
63
+
64
+ ```bash
65
+ biaws admin instance update local --password-file /caminho/privado/senha
66
+ ```
67
+
68
+ Se a política operacional já produz um backup externo verificável, a criação
69
+ local pode ser dispensada explicitamente com `--skip-backup`. O update preserva
70
+ o project name, o `.env`, os volumes e os bind mounts da instância. Ele não
71
+ executa `git pull` nem troca a versão do checkout.
72
+
73
+ A versão implantada só é registrada depois que `up -d --build --wait` termina
74
+ com sucesso. Ela também é exposta pelo `/api/health` e pelos labels dos
75
+ containers de API e UI. A versão nova é lida do `biaws-cli/package.json` da
76
+ raiz selecionada por `--root`/`BIAWS_ROOT`.
77
+
38
78
  ## Smoke test Docker real
39
79
 
40
80
  Em um checkout descartável com Docker e OpenSSL disponíveis:
package/docs/releasing.md CHANGED
@@ -69,9 +69,13 @@ não são aliases circulares. A remoção dos wrappers públicos só pode ocorre
69
69
  após aviso de depreciação em uma release `0.x`, telemetria ou evidência de
70
70
  adoção e um runbook de substituição atualizado.
71
71
 
72
- O pacote npm isolado cobre ajuda e API remota. Instância, MCP e catálogo local
73
- dependem do checkout completo; defina `BIAWS_ROOT` para sua raiz quando o
74
- executável global for chamado fora dela.
72
+ O pacote npm isolado cobre ajuda, API remota, instalação das skills e
73
+ configuração do MCP publicado. Operações de instância e publicação do catálogo
74
+ inicial dependem do checkout completo; defina `BIAWS_ROOT` para sua raiz quando
75
+ o executável global for chamado fora dela.
76
+
77
+ Publique primeiro a versão de `biaws-mcp` fixada pelo CLI. O CLI nunca deve ser
78
+ publicado apontando para uma versão do MCP que ainda não exista no registry.
75
79
 
76
80
  ## Exercício de release
77
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "biaws",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "CLI do Bondia Workspaces para administração, configuração e operação de workspaces",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -0,0 +1 @@
1
+ export { default } from "../../../commands/instance/update.js";
@@ -9,6 +9,11 @@ import { runSkillsCommand } from "./skills.js";
9
9
 
10
10
  const CODEX_BEGIN = "# BEGIN BIAWS MANAGED MCP";
11
11
  const CODEX_END = "# END BIAWS MANAGED MCP";
12
+ export const MCP_PACKAGE_NAME = "biaws-mcp";
13
+ export const MCP_PACKAGE_VERSION = "0.1.0";
14
+ export const MCP_PACKAGE_SPEC = `${MCP_PACKAGE_NAME}@${MCP_PACKAGE_VERSION}`;
15
+ const MCP_COMMAND = "npx";
16
+ const MCP_ARGS = ["--yes", MCP_PACKAGE_SPEC];
12
17
 
13
18
  function skillTarget(client, projectDirectory) {
14
19
  return path.join(
@@ -18,8 +23,18 @@ function skillTarget(client, projectDirectory) {
18
23
  );
19
24
  }
20
25
 
21
- function mcpEntrypoint(toolDirectory) {
22
- return path.resolve(toolDirectory, "..", "biaws-mcp", "src", "index.js");
26
+ function isExpectedClaudeServer(server) {
27
+ if (
28
+ server?.command === MCP_COMMAND &&
29
+ JSON.stringify(server.args || []) === JSON.stringify(MCP_ARGS)
30
+ ) {
31
+ return true;
32
+ }
33
+ return (
34
+ server?.command === "node" &&
35
+ server.args?.length === 1 &&
36
+ /(?:^|[\\/])biaws-mcp[\\/]src[\\/]index\.js$/u.test(server.args[0])
37
+ );
23
38
  }
24
39
 
25
40
  async function readOptional(filePath, fallback = "") {
@@ -33,7 +48,6 @@ async function readOptional(filePath, fallback = "") {
33
48
 
34
49
  async function writeCodexConfiguration(
35
50
  projectDirectory,
36
- entrypoint,
37
51
  envFile,
38
52
  workspaceId,
39
53
  force,
@@ -59,8 +73,8 @@ async function writeCodexConfiguration(
59
73
  }
60
74
  const managed = `${CODEX_BEGIN}
61
75
  [mcp_servers.biaws]
62
- command = "node"
63
- args = [${JSON.stringify(entrypoint)}]
76
+ command = ${JSON.stringify(MCP_COMMAND)}
77
+ args = ${JSON.stringify(MCP_ARGS)}
64
78
  env = {${envFile ? ` BIAWS_ENV_FILE = ${JSON.stringify(envFile)},` : ""} ISSUE_WORKSPACE_ID = ${JSON.stringify(workspaceId)} }
65
79
  ${CODEX_END}
66
80
  `;
@@ -71,7 +85,6 @@ ${CODEX_END}
71
85
 
72
86
  async function writeClaudeConfiguration(
73
87
  projectDirectory,
74
- entrypoint,
75
88
  envFile,
76
89
  workspaceId,
77
90
  force,
@@ -85,21 +98,19 @@ async function writeClaudeConfiguration(
85
98
  throw new Error(`JSON inválido em ${configPath}`);
86
99
  }
87
100
  config.mcpServers ||= {};
88
- if (config.mcpServers.biaws && !force) {
89
- const current = config.mcpServers.biaws;
90
- if (
91
- current.command !== "node" ||
92
- JSON.stringify(current.args || []) !== JSON.stringify([entrypoint])
93
- ) {
94
- throw new Error(
95
- `Já existe uma configuração Claude diferente para o servidor biaws em ${configPath}; use --force para substituí-la`,
96
- );
97
- }
101
+ if (
102
+ config.mcpServers.biaws &&
103
+ !force &&
104
+ !isExpectedClaudeServer(config.mcpServers.biaws)
105
+ ) {
106
+ throw new Error(
107
+ `Já existe uma configuração Claude diferente para o servidor biaws em ${configPath}; use --force para substituí-la`,
108
+ );
98
109
  }
99
110
  config.mcpServers.biaws = {
100
111
  type: "stdio",
101
- command: "node",
102
- args: [entrypoint],
112
+ command: MCP_COMMAND,
113
+ args: MCP_ARGS,
103
114
  env: {
104
115
  ...(envFile ? { BIAWS_ENV_FILE: envFile } : {}),
105
116
  ISSUE_WORKSPACE_ID: workspaceId,
@@ -144,19 +155,16 @@ async function configure(api, client, options, context) {
144
155
  const projectDirectory = path.resolve(options.project || process.cwd());
145
156
  const { workspaceId } = await resolveWorkspaceId(api, context.workspaceId);
146
157
  const target = skillTarget(client, projectDirectory);
147
- const entrypoint = mcpEntrypoint(context.toolDirectory);
148
158
  const configPath =
149
159
  client === "codex"
150
160
  ? await writeCodexConfiguration(
151
161
  projectDirectory,
152
- entrypoint,
153
162
  context.envFile,
154
163
  workspaceId,
155
164
  options.force,
156
165
  )
157
166
  : await writeClaudeConfiguration(
158
167
  projectDirectory,
159
- entrypoint,
160
168
  context.envFile,
161
169
  workspaceId,
162
170
  options.force,
@@ -173,6 +181,7 @@ async function configure(api, client, options, context) {
173
181
  configPath,
174
182
  skillTarget: target,
175
183
  installation,
184
+ mcpPackage: MCP_PACKAGE_SPEC,
176
185
  workspaceId,
177
186
  };
178
187
  if (options.json) console.log(JSON.stringify(result, null, 2));
@@ -197,9 +206,9 @@ async function requestStatus(url, headers = {}) {
197
206
  }
198
207
  }
199
208
 
200
- async function mcpStatus(entrypoint, envFile, workspaceId) {
209
+ async function mcpStatus(envFile, workspaceId) {
201
210
  return new Promise((resolve) => {
202
- const child = spawn(process.execPath, [entrypoint], {
211
+ const child = spawn(MCP_COMMAND, MCP_ARGS, {
203
212
  stdio: ["pipe", "pipe", "pipe"],
204
213
  env: {
205
214
  ...process.env,
@@ -222,9 +231,9 @@ async function mcpStatus(entrypoint, envFile, workspaceId) {
222
231
  () =>
223
232
  finish({
224
233
  ok: false,
225
- detail: errorOutput.trim() || "MCP não respondeu em 5 segundos",
234
+ detail: errorOutput.trim() || "MCP não respondeu em 30 segundos",
226
235
  }),
227
- 5_000,
236
+ 30_000,
228
237
  );
229
238
  child.stderr.on("data", (chunk) => {
230
239
  errorOutput += chunk;
@@ -283,20 +292,15 @@ async function mcpStatus(entrypoint, envFile, workspaceId) {
283
292
  });
284
293
  }
285
294
 
286
- function configurationStatus(
287
- client,
288
- contents,
289
- entrypoint,
290
- envFile,
291
- workspaceId,
292
- ) {
295
+ function configurationStatus(client, contents, envFile, workspaceId) {
293
296
  if (typeof contents !== "string") {
294
297
  return { ok: false, detail: "Configuração MCP não encontrada" };
295
298
  }
296
299
  if (client === "codex") {
297
300
  const ok =
298
301
  contents.includes("[mcp_servers.biaws]") &&
299
- contents.includes(JSON.stringify(entrypoint)) &&
302
+ contents.includes(`command = ${JSON.stringify(MCP_COMMAND)}`) &&
303
+ contents.includes(`args = ${JSON.stringify(MCP_ARGS)}`) &&
300
304
  (!envFile || contents.includes(JSON.stringify(envFile))) &&
301
305
  contents.includes(`ISSUE_WORKSPACE_ID = ${JSON.stringify(workspaceId)}`);
302
306
  return {
@@ -309,8 +313,8 @@ function configurationStatus(
309
313
  try {
310
314
  const server = JSON.parse(contents).mcpServers?.biaws;
311
315
  const ok =
312
- server?.command === "node" &&
313
- JSON.stringify(server.args || []) === JSON.stringify([entrypoint]) &&
316
+ server?.command === MCP_COMMAND &&
317
+ JSON.stringify(server.args || []) === JSON.stringify(MCP_ARGS) &&
314
318
  (!envFile || server.env?.BIAWS_ENV_FILE === envFile) &&
315
319
  server.env?.ISSUE_WORKSPACE_ID === workspaceId;
316
320
  return {
@@ -336,11 +340,10 @@ async function doctor(api, client, options, context) {
336
340
  client === "codex"
337
341
  ? path.join(projectDirectory, ".codex", "config.toml")
338
342
  : path.join(projectDirectory, ".mcp.json");
339
- const entrypoint = mcpEntrypoint(context.toolDirectory);
340
343
  const resolved = await resolveWorkspaceId(api, context.workspaceId);
341
344
  const [health, mcp, configContents, lock] = await Promise.all([
342
345
  requestStatus(`${context.apiUrl}/api/health`),
343
- mcpStatus(entrypoint, context.envFile, resolved.workspaceId),
346
+ mcpStatus(context.envFile, resolved.workspaceId),
344
347
  readOptional(configPath, null),
345
348
  readLock(target),
346
349
  ]);
@@ -354,7 +357,6 @@ async function doctor(api, client, options, context) {
354
357
  ...configurationStatus(
355
358
  client,
356
359
  configContents,
357
- entrypoint,
358
360
  context.envFile,
359
361
  resolved.workspaceId,
360
362
  ),
@@ -0,0 +1,164 @@
1
+ import { Flags } from "@oclif/core";
2
+
3
+ import { LocalInstanceCommand } from "../../baseCommands.js";
4
+ import {
5
+ contextFlags,
6
+ contextInput,
7
+ instanceArgument,
8
+ } from "../../instance/command.js";
9
+ import {
10
+ backupArguments,
11
+ executeInstanceScript,
12
+ getInstance,
13
+ getInstanceUpdateStatus,
14
+ updateInstance,
15
+ validateInstanceUpdate,
16
+ withPasswordFile,
17
+ } from "../../instance/service.js";
18
+
19
+ function versionCheckResult(instance, versionStatus, json) {
20
+ if (json) return versionStatus;
21
+ return [
22
+ `Instância: ${instance.name}`,
23
+ `Versão atual: ${versionStatus.currentVersion}`,
24
+ `Nova versão: ${versionStatus.newVersion}`,
25
+ `Atualização necessária: ${versionStatus.updateRequired ? "sim" : "não"}`,
26
+ ].join("\n");
27
+ }
28
+
29
+ function unchangedResult(instance, versionStatus, json) {
30
+ return json
31
+ ? { ...versionStatus, updated: false, backupCreated: false }
32
+ : `Instância ${instance.name} já está na versão ${versionStatus.newVersion}.`;
33
+ }
34
+
35
+ async function createUpdateBackup(command, instance, context, flags) {
36
+ if (flags["skip-backup"]) return "";
37
+
38
+ let password;
39
+ if (!flags["password-file"]) {
40
+ if (!context.isInteractive) {
41
+ command.error(
42
+ "Atualização exige --password-file ou --skip-backup em modo não interativo.",
43
+ { code: "BACKUP_PASSWORD_REQUIRED", exit: 2 },
44
+ );
45
+ }
46
+ password = await command.adapters.prompts.ask({
47
+ name: "password",
48
+ type: "password",
49
+ message: "Senha do backup anterior à atualização",
50
+ });
51
+ }
52
+
53
+ return withPasswordFile(
54
+ command.adapters.filesystem,
55
+ password,
56
+ flags["password-file"],
57
+ async (passwordFile) =>
58
+ executeInstanceScript(
59
+ backupArguments(instance, context, {
60
+ output: flags["backup-output"],
61
+ passwordFile,
62
+ }),
63
+ context,
64
+ command.adapters.processRunner,
65
+ { secrets: password ? [password] : [], silent: flags.json },
66
+ ),
67
+ );
68
+ }
69
+
70
+ function updatedResult(instance, versionStatus, flags, backupOutput) {
71
+ if (!flags.json) {
72
+ return `Instância ${instance.name} atualizada de ${versionStatus.currentVersion} para ${versionStatus.newVersion}. UI: ${instance.publicUrl}`;
73
+ }
74
+ return {
75
+ instance: instance.name,
76
+ operation: "update",
77
+ currentVersion: versionStatus.currentVersion,
78
+ newVersion: versionStatus.newVersion,
79
+ updateRequired: versionStatus.updateRequired,
80
+ updated: true,
81
+ backupCreated: !flags["skip-backup"],
82
+ backupOutput,
83
+ ui: instance.publicUrl,
84
+ };
85
+ }
86
+
87
+ export default class InstanceUpdate extends LocalInstanceCommand {
88
+ static description =
89
+ "reconstrói e atualiza os serviços preservando os dados da instância";
90
+ static args = { instance: instanceArgument };
91
+ static flags = {
92
+ ...contextFlags,
93
+ "backup-output": Flags.string({
94
+ description: "arquivo de destino do backup anterior à atualização",
95
+ }),
96
+ "password-file": Flags.string({
97
+ description: "arquivo privado contendo a senha do backup",
98
+ }),
99
+ "skip-backup": Flags.boolean({
100
+ description: "atualiza sem criar o backup de segurança",
101
+ exclusive: ["backup-output", "password-file"],
102
+ }),
103
+ check: Flags.boolean({
104
+ description: "compara a versão instalada com a versão da release",
105
+ exclusive: ["backup-output", "password-file", "skip-backup", "force"],
106
+ }),
107
+ force: Flags.boolean({
108
+ description: "reconstrói mesmo quando a instância já está na versão nova",
109
+ exclusive: ["check"],
110
+ }),
111
+ json: Flags.boolean({ description: "emite resultado JSON" }),
112
+ };
113
+
114
+ async run() {
115
+ const { args, flags } = await this.parse(InstanceUpdate);
116
+ const context = await this.localContext(contextInput(flags, args.instance));
117
+ const instance = await getInstance(
118
+ context,
119
+ this.adapters.filesystem,
120
+ args.instance,
121
+ );
122
+ const versionStatus = await getInstanceUpdateStatus(
123
+ instance,
124
+ context,
125
+ this.adapters.filesystem,
126
+ );
127
+ if (flags.check) {
128
+ this.output({ json: flags.json }).result(
129
+ versionCheckResult(instance, versionStatus, flags.json),
130
+ );
131
+ return;
132
+ }
133
+ if (!versionStatus.updateRequired && !flags.force) {
134
+ this.output({ json: flags.json }).result(
135
+ unchangedResult(instance, versionStatus, flags.json),
136
+ );
137
+ return;
138
+ }
139
+
140
+ await validateInstanceUpdate(
141
+ instance,
142
+ context,
143
+ this.adapters.processRunner,
144
+ );
145
+ const backupOutput = await createUpdateBackup(
146
+ this,
147
+ instance,
148
+ context,
149
+ flags,
150
+ );
151
+
152
+ await updateInstance(
153
+ instance,
154
+ context,
155
+ this.adapters.filesystem,
156
+ this.adapters.processRunner,
157
+ versionStatus.newVersion,
158
+ this.adapters.environment,
159
+ );
160
+ this.output({ json: flags.json }).result(
161
+ updatedResult(instance, versionStatus, flags, backupOutput),
162
+ );
163
+ }
164
+ }
@@ -8,6 +8,7 @@ export default class Instance extends LocalInstanceCommand {
8
8
  static examples = [
9
9
  "<%= config.bin %> instance setup",
10
10
  "<%= config.bin %> instance status",
11
+ "<%= config.bin %> instance update",
11
12
  "<%= config.bin %> instance backup",
12
13
  ];
13
14
 
@@ -18,6 +18,8 @@ const STORAGE_FIELDS = Object.freeze({
18
18
  secretPath: "BIAWS_SECRET_FILES_PATH",
19
19
  });
20
20
 
21
+ const UNKNOWN_VERSION = "unknown";
22
+
21
23
  function usageError(message, code, details) {
22
24
  throw new CliError(message, { code, details, exitCode: 2 });
23
25
  }
@@ -176,6 +178,50 @@ export async function getInstance(context, filesystem, name) {
176
178
  return instance;
177
179
  }
178
180
 
181
+ function normalizeVersion(value, label) {
182
+ const version = String(value || "").trim();
183
+ if (
184
+ !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(version)
185
+ ) {
186
+ throw new CliError(`${label} inválida: ${version || "ausente"}.`, {
187
+ code: "INVALID_RELEASE_VERSION",
188
+ });
189
+ }
190
+ return version;
191
+ }
192
+
193
+ export async function getInstanceUpdateStatus(instance, context, filesystem) {
194
+ const manifestPath = path.join(
195
+ context.repositoryRoot,
196
+ "biaws-cli",
197
+ "package.json",
198
+ );
199
+ let manifest;
200
+ try {
201
+ manifest = JSON.parse(await filesystem.readFile(manifestPath, "utf8"));
202
+ } catch (error) {
203
+ throw new CliError(
204
+ `Não foi possível ler a versão da release em ${manifestPath}.`,
205
+ {
206
+ code: "RELEASE_VERSION_READ_FAILED",
207
+ cause: error,
208
+ },
209
+ );
210
+ }
211
+ const newVersion = normalizeVersion(manifest.version, "Versão da release");
212
+ const installed = String(instance.env.BIAWS_VERSION || "").trim();
213
+ const currentVersion =
214
+ installed && installed !== UNKNOWN_VERSION
215
+ ? normalizeVersion(installed, "Versão instalada")
216
+ : UNKNOWN_VERSION;
217
+ return Object.freeze({
218
+ instance: instance.name,
219
+ currentVersion,
220
+ newVersion,
221
+ updateRequired: currentVersion !== newVersion,
222
+ });
223
+ }
224
+
179
225
  function resolveStorage(values, existing) {
180
226
  if (values.storage === "volumes") {
181
227
  if (Object.keys(STORAGE_FIELDS).some((field) => values[field])) {
@@ -396,6 +442,8 @@ export function composeArguments(instance, context, operation) {
396
442
  `biaws-${instance.name}`,
397
443
  ];
398
444
  if (operation === "start") return [...base, "up", "-d", "--wait"];
445
+ if (operation === "update") return [...base, "up", "-d", "--build", "--wait"];
446
+ if (operation === "validate") return [...base, "config", "--quiet"];
399
447
  if (operation === "stop") return [...base, "stop"];
400
448
  return [...base, "ps", "--format", "json"];
401
449
  }
@@ -405,15 +453,49 @@ export async function operateInstance(
405
453
  context,
406
454
  processRunner,
407
455
  operation,
456
+ options = {},
408
457
  ) {
409
458
  const result = await processRunner.run(
410
459
  "docker",
411
460
  composeArguments(instance, context, operation),
412
- { cwd: context.repositoryRoot, silent: true },
461
+ {
462
+ cwd: context.repositoryRoot,
463
+ env: options.environment,
464
+ silent: true,
465
+ },
413
466
  );
414
467
  return { instance: instance.name, operation, output: result.stdout.trim() };
415
468
  }
416
469
 
470
+ export async function validateInstanceUpdate(instance, context, processRunner) {
471
+ return operateInstance(instance, context, processRunner, "validate");
472
+ }
473
+
474
+ export async function updateInstance(
475
+ instance,
476
+ context,
477
+ filesystem,
478
+ processRunner,
479
+ version,
480
+ environment = {},
481
+ ) {
482
+ const normalizedVersion = normalizeVersion(version, "Nova versão");
483
+ const result = await operateInstance(
484
+ instance,
485
+ context,
486
+ processRunner,
487
+ "update",
488
+ { environment: { ...environment, BIAWS_VERSION: normalizedVersion } },
489
+ );
490
+ const envContents = await filesystem.readFile(instance.envFile, "utf8");
491
+ const versionLine = `BIAWS_VERSION=${normalizedVersion}`;
492
+ const nextEnv = /^BIAWS_VERSION=.*$/mu.test(envContents)
493
+ ? envContents.replace(/^BIAWS_VERSION=.*$/gmu, versionLine)
494
+ : `${envContents.replace(/\s*$/u, "")}\n${versionLine}\n`;
495
+ await filesystem.writeFile(instance.envFile, nextEnv, { mode: 0o600 });
496
+ return result;
497
+ }
498
+
417
499
  export function validateArchivePath(value) {
418
500
  const archive = path.resolve(String(value || ""));
419
501
  if (!value || archive === path.parse(archive).root) {