borgmcp 2.1.0 → 2.2.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/README.md +2 -2
- package/dist/assimilate-cmd.d.ts +11 -2
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +60 -85
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +26 -8
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +7 -5
- package/dist/cli-help.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/regen-format.d.ts +1 -6
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +9 -8
- package/dist/regen-format.js.map +1 -1
- package/dist/remote-client.js +1 -1
- package/dist/remote-client.js.map +1 -1
- package/dist/repository-cube-init.d.ts +16 -1
- package/dist/repository-cube-init.d.ts.map +1 -1
- package/dist/repository-cube-init.js +167 -22
- package/dist/repository-cube-init.js.map +1 -1
- package/dist/server-errors.d.ts +11 -0
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js +20 -0
- package/dist/server-errors.js.map +1 -1
- package/dist/server-facade.d.ts +2 -1
- package/dist/server-facade.d.ts.map +1 -1
- package/dist/server-facade.js +16 -10
- package/dist/server-facade.js.map +1 -1
- package/dist/server-handshake.d.ts +32 -2
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +161 -8
- package/dist/server-handshake.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +9 -7
- package/docs/LOCAL_SERVER.md +19 -13
- package/docs/RELEASING.md +21 -5
- package/package.json +2 -2
- package/src/assimilate-cmd.ts +85 -69
- package/src/assimilate-deps.ts +37 -7
- package/src/cli-help.ts +7 -5
- package/src/index.ts +5 -1
- package/src/regen-format.ts +11 -9
- package/src/remote-client.ts +1 -1
- package/src/repository-cube-init.ts +202 -23
- package/src/server-errors.ts +27 -0
- package/src/server-facade.ts +16 -10
- package/src/server-handshake.ts +226 -8
|
@@ -3,15 +3,23 @@ import {
|
|
|
3
3
|
LEGACY_DEFAULT_TEMPLATE_LABEL,
|
|
4
4
|
} from 'borgmcp-shared/templates';
|
|
5
5
|
import type {
|
|
6
|
+
AssociateRepositoryCubeResponse,
|
|
6
7
|
CreateCubeRepository,
|
|
7
8
|
CreateCubeResponse,
|
|
8
9
|
CubeTemplate,
|
|
10
|
+
ResolvedRepositoryCube,
|
|
11
|
+
ResolveRepositoryCubeResponse,
|
|
9
12
|
} from 'borgmcp-shared/protocol';
|
|
10
13
|
import { shellEscape } from './shell-escape.js';
|
|
11
14
|
import type {
|
|
12
15
|
GitRepositoryContext,
|
|
13
16
|
RepositoryAssociation,
|
|
14
17
|
} from './repository-identity.js';
|
|
18
|
+
import {
|
|
19
|
+
BorgServerError,
|
|
20
|
+
CubeCreationConfirmationError,
|
|
21
|
+
CubeCreationOutcomeUnknownError,
|
|
22
|
+
} from './server-errors.js';
|
|
15
23
|
|
|
16
24
|
export interface RepositoryCubeDetail {
|
|
17
25
|
id: string;
|
|
@@ -25,6 +33,8 @@ export interface RepositoryCubeCreation {
|
|
|
25
33
|
cube: RepositoryCubeDetail;
|
|
26
34
|
}
|
|
27
35
|
|
|
36
|
+
export type RepositoryCubeResolution = ResolveRepositoryCubeResponse;
|
|
37
|
+
|
|
28
38
|
export interface RepositoryCubeInitFlags {
|
|
29
39
|
cubeName?: string;
|
|
30
40
|
template?: string;
|
|
@@ -39,6 +49,13 @@ export interface RepositoryCubeInitDeps {
|
|
|
39
49
|
getIdentity(context: GitRepositoryContext): Promise<CreateCubeRepository>;
|
|
40
50
|
getAssociation(repository: CreateCubeRepository): Promise<RepositoryAssociation | null>;
|
|
41
51
|
saveAssociation(repository: CreateCubeRepository, association: RepositoryAssociation): Promise<void>;
|
|
52
|
+
resolveAssociation(repository: CreateCubeRepository, workingRepoName: string): Promise<RepositoryCubeResolution>;
|
|
53
|
+
listCubes(): Promise<Array<{ id: string; name: string }>>;
|
|
54
|
+
associateCube(input: {
|
|
55
|
+
cubeId: string;
|
|
56
|
+
workingRepoName: string;
|
|
57
|
+
repository: CreateCubeRepository;
|
|
58
|
+
}): Promise<AssociateRepositoryCubeResponse>;
|
|
42
59
|
getCube(cubeId: string): Promise<RepositoryCubeDetail>;
|
|
43
60
|
createCube(input: {
|
|
44
61
|
name: string;
|
|
@@ -59,6 +76,13 @@ export class RepositoryAssociationSaveError extends Error {
|
|
|
59
76
|
}
|
|
60
77
|
}
|
|
61
78
|
|
|
79
|
+
export class RepositoryAssociationConfirmationError extends Error {
|
|
80
|
+
constructor() {
|
|
81
|
+
super('The Borg server did not confirm the repository cube association.');
|
|
82
|
+
this.name = 'RepositoryAssociationConfirmationError';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
export class PromptInterruptedError extends Error {
|
|
63
87
|
constructor() {
|
|
64
88
|
super('prompt interrupted');
|
|
@@ -79,15 +103,19 @@ function presentation(template: CubeTemplate) {
|
|
|
79
103
|
return NEW_CUBE_TEMPLATE_PRESENTATIONS.find((candidate) => candidate.name === template)!;
|
|
80
104
|
}
|
|
81
105
|
|
|
82
|
-
async function ask(
|
|
106
|
+
async function ask(
|
|
107
|
+
deps: RepositoryCubeInitDeps,
|
|
108
|
+
message: string,
|
|
109
|
+
operation: 'creation' | 'adoption' = 'creation',
|
|
110
|
+
): Promise<{ value: string } | { stop: number }> {
|
|
83
111
|
try {
|
|
84
112
|
return { value: await deps.prompt(message) };
|
|
85
113
|
} catch (error) {
|
|
86
114
|
if (error instanceof PromptInterruptedError) {
|
|
87
|
-
deps.write(
|
|
115
|
+
deps.write(`\nCube ${operation} cancelled. No cube, repository binding, or drone was created.\n`);
|
|
88
116
|
return { stop: 130 };
|
|
89
117
|
}
|
|
90
|
-
deps.write(
|
|
118
|
+
deps.write(`Input ended before cube ${operation}. No cube, repository binding, or drone was created.\n`);
|
|
91
119
|
return { stop: 1 };
|
|
92
120
|
}
|
|
93
121
|
}
|
|
@@ -101,10 +129,11 @@ function renderResult(
|
|
|
101
129
|
serverOrigin: string;
|
|
102
130
|
mode: 'assimilate' | 'cube-init';
|
|
103
131
|
creationOptionsUnused: boolean;
|
|
132
|
+
adopted?: boolean;
|
|
104
133
|
},
|
|
105
134
|
): void {
|
|
106
135
|
const lines = [
|
|
107
|
-
input.existing ? 'Cube already initialized.' : 'Cube created.',
|
|
136
|
+
input.adopted ? 'Existing cube associated with this repository.' : input.existing ? 'Cube already initialized.' : 'Cube created.',
|
|
108
137
|
` Name: ${input.response.name}`,
|
|
109
138
|
` Template: ${presentation(input.response.template).label}`,
|
|
110
139
|
` Repository: ${input.root}`,
|
|
@@ -129,22 +158,15 @@ export async function initializeRepositoryCube(input: {
|
|
|
129
158
|
context: GitRepositoryContext;
|
|
130
159
|
serverOrigin: string;
|
|
131
160
|
flags: RepositoryCubeInitFlags;
|
|
161
|
+
canCreate?: boolean;
|
|
132
162
|
}, deps: RepositoryCubeInitDeps): Promise<RepositoryCubeInitResult> {
|
|
133
163
|
const repository = await deps.getIdentity(input.context);
|
|
134
164
|
const association = await deps.getAssociation(repository);
|
|
135
|
-
const
|
|
136
|
-
input.flags.
|
|
137
|
-
|
|
165
|
+
const creationOptionsRequested = input.flags.cubeName !== undefined ||
|
|
166
|
+
input.flags.template !== undefined || input.flags.noTemplate === true || input.flags.yes === true;
|
|
167
|
+
const creationOptionsUnused = association !== null && creationOptionsRequested;
|
|
138
168
|
if (association) {
|
|
139
169
|
const cube = await deps.getCube(association.cubeId);
|
|
140
|
-
const authoritativeAssociation = { ...association, name: cube.name };
|
|
141
|
-
if (cube.name !== association.name) {
|
|
142
|
-
try {
|
|
143
|
-
await deps.saveAssociation(repository, authoritativeAssociation);
|
|
144
|
-
} catch {
|
|
145
|
-
throw new RepositoryAssociationSaveError();
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
170
|
const response: CreateCubeResponse = {
|
|
149
171
|
result: 'resolved',
|
|
150
172
|
cube_id: association.cubeId,
|
|
@@ -167,6 +189,123 @@ export async function initializeRepositoryCube(input: {
|
|
|
167
189
|
return { kind: 'success', creation: { response, cube }, existing: true };
|
|
168
190
|
}
|
|
169
191
|
|
|
192
|
+
const saveResolvedAssociation = async (
|
|
193
|
+
response: ResolvedRepositoryCube,
|
|
194
|
+
options: { adopted: boolean; creationOptionsUnused: boolean },
|
|
195
|
+
): Promise<RepositoryCubeInitResult> => {
|
|
196
|
+
if (
|
|
197
|
+
response.repository.kind !== repository.kind ||
|
|
198
|
+
response.repository.value !== repository.value ||
|
|
199
|
+
response.working_repo_name !== input.context.derivedName
|
|
200
|
+
) {
|
|
201
|
+
throw new RepositoryAssociationConfirmationError();
|
|
202
|
+
}
|
|
203
|
+
const cube = await deps.getCube(response.cube_id);
|
|
204
|
+
if (
|
|
205
|
+
cube.id !== response.cube_id ||
|
|
206
|
+
cube.name !== response.name ||
|
|
207
|
+
!cube.roles.some((role) => role.id === response.human_seat_role_id) ||
|
|
208
|
+
!cube.roles.some((role) => role.id === response.default_worker_role_id)
|
|
209
|
+
) {
|
|
210
|
+
throw new RepositoryAssociationConfirmationError();
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
await deps.saveAssociation(repository, {
|
|
214
|
+
cubeId: response.cube_id,
|
|
215
|
+
name: response.name,
|
|
216
|
+
workingRepoName: response.working_repo_name,
|
|
217
|
+
template: response.template,
|
|
218
|
+
});
|
|
219
|
+
} catch {
|
|
220
|
+
throw new RepositoryAssociationSaveError();
|
|
221
|
+
}
|
|
222
|
+
renderResult(deps, {
|
|
223
|
+
existing: true,
|
|
224
|
+
adopted: options.adopted,
|
|
225
|
+
response,
|
|
226
|
+
root: input.context.root,
|
|
227
|
+
serverOrigin: input.serverOrigin,
|
|
228
|
+
mode: input.mode,
|
|
229
|
+
creationOptionsUnused: options.creationOptionsUnused,
|
|
230
|
+
});
|
|
231
|
+
return { kind: 'success', creation: { response, cube }, existing: true };
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const serverAssociation = await deps.resolveAssociation(repository, input.context.derivedName);
|
|
235
|
+
if (serverAssociation.result === 'resolved') {
|
|
236
|
+
return saveResolvedAssociation(serverAssociation, {
|
|
237
|
+
adopted: false,
|
|
238
|
+
creationOptionsUnused: creationOptionsRequested,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const proposedName = input.flags.cubeName?.trim() ?? input.context.derivedName;
|
|
243
|
+
if (!validRepositoryCubeName(proposedName)) {
|
|
244
|
+
deps.write(`${NAME_ERROR}\n`);
|
|
245
|
+
return { kind: 'stop', code: 1 };
|
|
246
|
+
}
|
|
247
|
+
const accessibleCubes = await deps.listCubes();
|
|
248
|
+
const adoptExactMatch = async (name: string): Promise<RepositoryCubeInitResult | null> => {
|
|
249
|
+
const matches = accessibleCubes.filter((cube) => cube.name === name);
|
|
250
|
+
if (matches.length === 0) return null;
|
|
251
|
+
if (matches.length > 1) {
|
|
252
|
+
deps.write(`More than one accessible cube is named '${name}'. No cube, repository binding, or drone was created. Ask the server operator for an unambiguous repository cube grant.\n`);
|
|
253
|
+
return { kind: 'stop', code: 1 };
|
|
254
|
+
}
|
|
255
|
+
if (!deps.isTTY() || input.flags.yes) {
|
|
256
|
+
deps.write(
|
|
257
|
+
'Adopting an existing cube requires interactive confirmation; --yes is not accepted here.\n' +
|
|
258
|
+
'Rerun without --yes in an interactive terminal. No cube, repository binding, or drone was created.\n',
|
|
259
|
+
);
|
|
260
|
+
return { kind: 'stop', code: 1 };
|
|
261
|
+
}
|
|
262
|
+
deps.write(
|
|
263
|
+
`Found an existing cube matching this repository:\n` +
|
|
264
|
+
` cube: ${matches[0].name}\n` +
|
|
265
|
+
` repository: ${input.context.root}\n` +
|
|
266
|
+
` server: ${input.serverOrigin}\n`,
|
|
267
|
+
);
|
|
268
|
+
while (true) {
|
|
269
|
+
const answer = await ask(deps, 'Link this repository to that cube? [y/N]: ', 'adoption');
|
|
270
|
+
if ('stop' in answer) return { kind: 'stop', code: answer.stop };
|
|
271
|
+
const confirmation = answer.value.trim().toLowerCase();
|
|
272
|
+
if (confirmation === 'y' || confirmation === 'yes') break;
|
|
273
|
+
if (confirmation === '' || confirmation === 'n' || confirmation === 'no') {
|
|
274
|
+
deps.write('No cube, repository binding, or drone was created.\n');
|
|
275
|
+
return { kind: 'stop', code: 0 };
|
|
276
|
+
}
|
|
277
|
+
deps.write('Enter y or n.\n');
|
|
278
|
+
}
|
|
279
|
+
const associated = await deps.associateCube({
|
|
280
|
+
cubeId: matches[0].id,
|
|
281
|
+
workingRepoName: input.context.derivedName,
|
|
282
|
+
repository,
|
|
283
|
+
});
|
|
284
|
+
if (associated.cube_id !== matches[0].id) {
|
|
285
|
+
throw new RepositoryAssociationConfirmationError();
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const confirmed = await deps.resolveAssociation(repository, input.context.derivedName);
|
|
289
|
+
if (confirmed.result !== 'resolved' || confirmed.cube_id !== associated.cube_id) {
|
|
290
|
+
throw new RepositoryAssociationConfirmationError();
|
|
291
|
+
}
|
|
292
|
+
return await saveResolvedAssociation(confirmed, { adopted: true, creationOptionsUnused: creationOptionsRequested });
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (error instanceof RepositoryAssociationSaveError) throw error;
|
|
295
|
+
throw new RepositoryAssociationConfirmationError();
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const proposedAdoption = await adoptExactMatch(proposedName);
|
|
300
|
+
if (proposedAdoption) return proposedAdoption;
|
|
301
|
+
|
|
302
|
+
if (input.canCreate === false) {
|
|
303
|
+
deps.write(
|
|
304
|
+
`This enrolled client cannot create a cube on ${input.serverOrigin}. Ask the server operator to grant access to a cube, then rerun borg assimilate --host ${shellEscape(input.serverOrigin)}.\n`,
|
|
305
|
+
);
|
|
306
|
+
return { kind: 'stop', code: 1 };
|
|
307
|
+
}
|
|
308
|
+
|
|
170
309
|
if (input.flags.noTemplate) {
|
|
171
310
|
deps.write('--no-template is not supported for repository cube creation. Use --template software-dev or --template starter.\n');
|
|
172
311
|
return { kind: 'stop', code: 1 };
|
|
@@ -201,6 +340,10 @@ export async function initializeRepositoryCube(input: {
|
|
|
201
340
|
deps.write(`${NAME_ERROR}\n`);
|
|
202
341
|
return { kind: 'stop', code: 1 };
|
|
203
342
|
}
|
|
343
|
+
if (name !== proposedName) {
|
|
344
|
+
const editedNameAdoption = await adoptExactMatch(name);
|
|
345
|
+
if (editedNameAdoption) return editedNameAdoption;
|
|
346
|
+
}
|
|
204
347
|
|
|
205
348
|
let template = input.flags.template as 'software-dev' | 'starter' | undefined;
|
|
206
349
|
if (!template && deps.isTTY() && !input.flags.yes) {
|
|
@@ -241,7 +384,7 @@ export async function initializeRepositoryCube(input: {
|
|
|
241
384
|
break;
|
|
242
385
|
}
|
|
243
386
|
if (confirmation === 'n' || confirmation === 'no') {
|
|
244
|
-
deps.write('Cube creation cancelled.
|
|
387
|
+
deps.write('Cube creation cancelled. No cube, repository binding, or drone was created.\n');
|
|
245
388
|
return { kind: 'stop', code: 0 };
|
|
246
389
|
}
|
|
247
390
|
deps.write('Enter y or n.\n');
|
|
@@ -250,24 +393,60 @@ export async function initializeRepositoryCube(input: {
|
|
|
250
393
|
|
|
251
394
|
deps.write('Creating cube...\n');
|
|
252
395
|
const workingRepoName = input.context.derivedName;
|
|
253
|
-
|
|
396
|
+
let creation: RepositoryCubeCreation;
|
|
397
|
+
try {
|
|
398
|
+
creation = await deps.createCube({ name, workingRepoName, repository, template });
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (
|
|
401
|
+
error instanceof BorgServerError ||
|
|
402
|
+
error instanceof CubeCreationConfirmationError ||
|
|
403
|
+
error instanceof CubeCreationOutcomeUnknownError
|
|
404
|
+
) {
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
throw new CubeCreationOutcomeUnknownError();
|
|
408
|
+
}
|
|
409
|
+
let confirmed: RepositoryCubeResolution;
|
|
410
|
+
try {
|
|
411
|
+
confirmed = await deps.resolveAssociation(repository, workingRepoName);
|
|
412
|
+
} catch {
|
|
413
|
+
throw new CubeCreationConfirmationError('The server did not return authoritative repository association state.');
|
|
414
|
+
}
|
|
415
|
+
if (
|
|
416
|
+
confirmed.result !== 'resolved' ||
|
|
417
|
+
confirmed.repository.kind !== repository.kind ||
|
|
418
|
+
confirmed.repository.value !== repository.value ||
|
|
419
|
+
confirmed.working_repo_name !== workingRepoName ||
|
|
420
|
+
confirmed.cube_id !== creation.response.cube_id ||
|
|
421
|
+
confirmed.name !== creation.response.name ||
|
|
422
|
+
creation.cube.id !== confirmed.cube_id ||
|
|
423
|
+
creation.cube.name !== confirmed.name ||
|
|
424
|
+
!creation.cube.roles.some((role) => role.id === confirmed.human_seat_role_id) ||
|
|
425
|
+
!creation.cube.roles.some((role) => role.id === confirmed.default_worker_role_id)
|
|
426
|
+
) {
|
|
427
|
+
throw new CubeCreationConfirmationError('The server did not confirm the created cube through authoritative repository resolution.');
|
|
428
|
+
}
|
|
254
429
|
try {
|
|
255
430
|
await deps.saveAssociation(repository, {
|
|
256
|
-
cubeId:
|
|
257
|
-
name:
|
|
258
|
-
workingRepoName:
|
|
259
|
-
template:
|
|
431
|
+
cubeId: confirmed.cube_id,
|
|
432
|
+
name: confirmed.name,
|
|
433
|
+
workingRepoName: confirmed.working_repo_name,
|
|
434
|
+
template: confirmed.template,
|
|
260
435
|
});
|
|
261
436
|
} catch {
|
|
262
437
|
throw new RepositoryAssociationSaveError();
|
|
263
438
|
}
|
|
264
439
|
renderResult(deps, {
|
|
265
440
|
existing: creation.response.result === 'resolved',
|
|
266
|
-
response:
|
|
441
|
+
response: confirmed,
|
|
267
442
|
root: input.context.root,
|
|
268
443
|
serverOrigin: input.serverOrigin,
|
|
269
444
|
mode: input.mode,
|
|
270
445
|
creationOptionsUnused: false,
|
|
271
446
|
});
|
|
272
|
-
return {
|
|
447
|
+
return {
|
|
448
|
+
kind: 'success',
|
|
449
|
+
creation: { response: confirmed, cube: creation.cube },
|
|
450
|
+
existing: creation.response.result === 'resolved',
|
|
451
|
+
};
|
|
273
452
|
}
|
package/src/server-errors.ts
CHANGED
|
@@ -106,6 +106,33 @@ export class CubeCreationConfirmationError extends Error {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
export type RepositoryAssociationFailure =
|
|
110
|
+
| 'repository-already-associated'
|
|
111
|
+
| 'cube-already-associated'
|
|
112
|
+
| 'access-denied'
|
|
113
|
+
| 'invalid-cube';
|
|
114
|
+
|
|
115
|
+
export class RepositoryAssociationOperationError extends Error {
|
|
116
|
+
constructor(public readonly failure: RepositoryAssociationFailure) {
|
|
117
|
+
super('Borg server rejected the repository cube association');
|
|
118
|
+
this.name = 'RepositoryAssociationOperationError';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class RepositoryAssociationOutcomeUnknownError extends Error {
|
|
123
|
+
constructor() {
|
|
124
|
+
super('Repository cube association outcome is unknown.');
|
|
125
|
+
this.name = 'RepositoryAssociationOutcomeUnknownError';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class RepositoryAssociationResolutionError extends Error {
|
|
130
|
+
constructor() {
|
|
131
|
+
super('Repository cube association could not be resolved.');
|
|
132
|
+
this.name = 'RepositoryAssociationResolutionError';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
109
136
|
/** Exact retired TTL-replacement state: two saved bearers and no safe implicit winner. */
|
|
110
137
|
export class LegacySessionCredentialCollisionError extends Error {
|
|
111
138
|
constructor(public readonly origin: string) {
|
package/src/server-facade.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn as spawnChild, type SpawnOptions } from 'node:child_process';
|
|
|
2
2
|
import { constants } from 'node:os';
|
|
3
3
|
import { cubeInitHelpText, isHelpFlag, serverHelpText } from './cli-help.js';
|
|
4
4
|
|
|
5
|
-
export const SERVER_LIFECYCLE_COMMANDS = ['setup', 'start', 'stop', 'status', 'update', 'invite'] as const;
|
|
5
|
+
export const SERVER_LIFECYCLE_COMMANDS = ['setup', 'start', 'stop', 'status', 'update', 'invite', 'dashboard'] as const;
|
|
6
6
|
export type ServerLifecycleCommand = typeof SERVER_LIFECYCLE_COMMANDS[number];
|
|
7
7
|
|
|
8
8
|
export type ParsedServerFacadeArgs =
|
|
@@ -48,6 +48,7 @@ export interface ServerFacadeProcessDeps {
|
|
|
48
48
|
args: readonly string[],
|
|
49
49
|
options: Pick<SpawnOptions, 'shell' | 'stdio'>,
|
|
50
50
|
): ServerFacadeChild;
|
|
51
|
+
isInteractiveTerminal(): boolean;
|
|
51
52
|
addSignalListener(signal: NodeJS.Signals, listener: () => void): void;
|
|
52
53
|
removeSignalListener(signal: NodeJS.Signals, listener: () => void): void;
|
|
53
54
|
}
|
|
@@ -70,6 +71,7 @@ export type ServerFacadeProcessResult =
|
|
|
70
71
|
|
|
71
72
|
const defaultProcessDeps: ServerFacadeProcessDeps = {
|
|
72
73
|
spawn: (command, args, options) => spawnChild(command, [...args], options),
|
|
74
|
+
isInteractiveTerminal: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
73
75
|
addSignalListener: (signal, listener) => process.on(signal, listener),
|
|
74
76
|
removeSignalListener: (signal, listener) => process.off(signal, listener),
|
|
75
77
|
};
|
|
@@ -125,7 +127,7 @@ function inertCommand(command: string): string {
|
|
|
125
127
|
export function unknownServerCommandText(command: string): string {
|
|
126
128
|
return (
|
|
127
129
|
`Unknown server command: ${inertCommand(command)}.\n` +
|
|
128
|
-
`Available commands: setup, start, stop, status, update, invite, cube init.\n` +
|
|
130
|
+
`Available commands: setup, start, stop, status, update, invite, dashboard, cube init.\n` +
|
|
129
131
|
`Next: run borg server --help.\n`
|
|
130
132
|
);
|
|
131
133
|
}
|
|
@@ -159,7 +161,6 @@ export function runServerFacadeProcess(
|
|
|
159
161
|
[input.command, ...input.args],
|
|
160
162
|
{ shell: false, stdio: 'inherit' },
|
|
161
163
|
);
|
|
162
|
-
const signals = ['SIGINT', 'SIGTERM'] as const;
|
|
163
164
|
|
|
164
165
|
return new Promise((resolve) => {
|
|
165
166
|
let settled = false;
|
|
@@ -177,13 +178,18 @@ export function runServerFacadeProcess(
|
|
|
177
178
|
resolve(result);
|
|
178
179
|
};
|
|
179
180
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
deps.
|
|
186
|
-
}
|
|
181
|
+
const forwardSigint = () => {
|
|
182
|
+
// An interactive terminal has already sent SIGINT to this foreground
|
|
183
|
+
// group, including the server. Forwarding again kills its cleanup path.
|
|
184
|
+
// Node exposes no signal origin, so parent-only SIGINT while attached to
|
|
185
|
+
// a TTY is intentionally treated as terminal delivery and is not forwarded.
|
|
186
|
+
if (!deps.isInteractiveTerminal()) child.kill('SIGINT');
|
|
187
|
+
};
|
|
188
|
+
const forwardSigterm = () => child.kill('SIGTERM');
|
|
189
|
+
forwarders.set('SIGINT', forwardSigint);
|
|
190
|
+
forwarders.set('SIGTERM', forwardSigterm);
|
|
191
|
+
deps.addSignalListener('SIGINT', forwardSigint);
|
|
192
|
+
deps.addSignalListener('SIGTERM', forwardSigterm);
|
|
187
193
|
|
|
188
194
|
child.once('error', (error) => settle({ kind: 'spawn-error', error }));
|
|
189
195
|
child.once('exit', (code, signal) => {
|