engineering-memory 1.11.26 → 1.11.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.11.26",
3
+ "version": "1.11.28",
4
4
  "description": "Installs the Engineering Memory skill and its local MCP bridge. Sign in after installing; your organization and project are resolved from your account.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1,3 +1,3 @@
1
1
  {
2
- "gitHead": "0f86f662e39b6ace530d89827a5b14018f1b68fe"
2
+ "gitHead": "7ca14a854db07f8fda3823f303e8a7a6accbebff"
3
3
  }
@@ -70,6 +70,7 @@ export const endpoints = {
70
70
  taskDeliveryAnswer: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/delivery/answer`,
71
71
  taskDeliveryDeliver: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/delivery/deliver`,
72
72
  taskDeliveryCancel: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/delivery/cancel`,
73
+ taskReviewRequest: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/review-request`,
73
74
  projectSetup: '/projects/setup',
74
75
  projectList: '/projects',
75
76
  projectResolve: '/projects/resolve',
@@ -280,14 +280,7 @@ export class GitInspector {
280
280
  const upstream = branch
281
281
  ? await this.gitValue(repoRoot, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
282
282
  : null;
283
- const listed = await this.runner.run('git', ['remote'], { cwd: repoRoot });
284
- const remotes = listed.exitCode === 0 ? listed.stdout.split(/\r?\n/).filter(Boolean) : [];
285
- const configured = (branch &&
286
- (await this.gitValue(repoRoot, ['config', '--get', `branch.${branch}.pushRemote`]))) ||
287
- (await this.gitValue(repoRoot, ['config', '--get', 'remote.pushDefault'])) ||
288
- (branch && (await this.gitValue(repoRoot, ['config', '--get', `branch.${branch}.remote`]))) ||
289
- 'origin';
290
- const remote = remotes.includes(configured) ? configured : null;
283
+ const remote = await this.pushRemote(repoRoot, branch);
291
284
  const pushUrl = remote
292
285
  ? await this.gitValue(repoRoot, ['remote', 'get-url', '--push', remote])
293
286
  : null;
@@ -308,6 +301,32 @@ export class GitInspector {
308
301
  headCommit: await this.gitValue(repoRoot, ['rev-parse', '--verify', '-q', 'HEAD']),
309
302
  };
310
303
  }
304
+ async branchPushUrl(repoRoot, branch) {
305
+ await this.validateBranch(repoRoot, branch);
306
+ const remote = await this.pushRemote(repoRoot, branch);
307
+ const pushUrl = remote
308
+ ? await this.gitValue(repoRoot, ['remote', 'get-url', '--push', remote])
309
+ : null;
310
+ return pushUrl ? withoutCredentials(pushUrl) : null;
311
+ }
312
+ async branchCommit(repoRoot, branch) {
313
+ await this.validateBranch(repoRoot, branch);
314
+ const result = await this.runner.run('git', ['log', '-1', '--format=%H%x00%s', `refs/heads/${branch}`, '--'], { cwd: repoRoot });
315
+ const [commit, subject] = result.stdout.trim().split('\0');
316
+ return result.exitCode === 0 && commit && /^([a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)
317
+ ? { commit, subject: subject ?? '' }
318
+ : null;
319
+ }
320
+ async pushRemote(repoRoot, branch) {
321
+ const listed = await this.runner.run('git', ['remote'], { cwd: repoRoot });
322
+ const remotes = listed.exitCode === 0 ? listed.stdout.split(/\r?\n/).filter(Boolean) : [];
323
+ const configured = (branch &&
324
+ (await this.gitValue(repoRoot, ['config', '--get', `branch.${branch}.pushRemote`]))) ||
325
+ (await this.gitValue(repoRoot, ['config', '--get', 'remote.pushDefault'])) ||
326
+ (branch && (await this.gitValue(repoRoot, ['config', '--get', `branch.${branch}.remote`]))) ||
327
+ 'origin';
328
+ return remotes.includes(configured) ? configured : null;
329
+ }
311
330
  async protectedBranchNames(repoRoot) {
312
331
  const names = new Set(['main', 'master', 'develop', 'prod']);
313
332
  const fallback = await this.defaultBranch(repoRoot);
@@ -63,6 +63,28 @@ export const bundledTexts = {
63
63
  'gitPreferences.save': 'Save this choice',
64
64
  'gitPreferences.saved': 'This branch is recorded as the project default now, for every member.',
65
65
  'gitPreferences.unchanged': 'Nothing changes. This role stays as it is and can be answered another time.',
66
+ 'gitlabToken.address': 'GitLab address',
67
+ 'gitlabToken.addressHint': 'The address you open GitLab with in the browser, such as https://gitlab.example.com, with the path if GitLab runs under one.',
68
+ 'gitlabToken.certificate': 'This computer does not trust the certificate of {address}. If your company uses its own certificate, set the NODE_EXTRA_CA_CERTS environment variable to its file and restart the coding agent.',
69
+ 'gitlabToken.expired': 'This link has expired or was already used. Ask the coding agent for a new one.',
70
+ 'gitlabToken.failed': 'The request could not be handled. Try again in a moment.',
71
+ 'gitlabToken.gitlabError': 'GitLab answered with an error ({status}), so the token was not saved. Try again in a moment.',
72
+ 'gitlabToken.intro': "Engineering Memory opens and follows your merge requests on this GitLab from this computer. The token is kept on this computer in the operating system's protected credential storage; it is not sent to Engineering Memory's server or to the coding agent.",
73
+ 'gitlabToken.invalidAddress': 'Enter the address as https://host, with a path if GitLab runs under one. Plain http works only for a GitLab on this computer, and an address given as an IP number cannot be used.',
74
+ 'gitlabToken.missingToken': 'Enter the token.',
75
+ 'gitlabToken.noApiScope': 'GitLab accepted the token, but it has no access to the API. Create a token with the api scope, or read_api for following merge requests only.',
76
+ 'gitlabToken.notGitLab': '{address} does not answer like GitLab. Check the address, including any path GitLab runs under.',
77
+ 'gitlabToken.redirected': "{address} answered with a redirect, so it is probably not GitLab's own address. Check https and any path GitLab runs under.",
78
+ 'gitlabToken.rejected': 'GitLab did not accept this token. Check that it was copied completely and has not expired or been revoked.',
79
+ 'gitlabToken.remove': 'Remove the saved token',
80
+ 'gitlabToken.removed': 'No token for this GitLab address is saved on this computer any more. You can close this page.',
81
+ 'gitlabToken.save': 'Check and save',
82
+ 'gitlabToken.saved': 'Saved. GitLab accepted the token. You can close this page and go back to the coding agent.',
83
+ 'gitlabToken.savedReadOnly': 'Saved. GitLab accepted the token, but it can only read: Engineering Memory can follow merge requests with it and cannot open them. To open them too, save a token with the api scope.',
84
+ 'gitlabToken.title': 'GitLab token for Engineering Memory',
85
+ 'gitlabToken.token': 'Personal access token',
86
+ 'gitlabToken.tokenHint': 'Create it in GitLab under Preferences, Access tokens. With the api scope Engineering Memory can open merge requests; read_api is enough for following them.',
87
+ 'gitlabToken.unreachable': 'This computer cannot reach {address}. If GitLab is only reachable from your company network, connect to it (for example through the VPN) and try again.',
66
88
  'inspection.defer': 'Not now',
67
89
  'inspection.deferDetail': 'Nothing is inspected and nothing is written. Your answer is recorded, so inspecting this source later is a new request.',
68
90
  'inspection.example': 'When the inspection finishes, a memory record is proposed for each screen, service and flow, and you decide which of them get published.',
@@ -467,6 +489,28 @@ export const bundledTexts = {
467
489
  'gitPreferences.save': 'Bu tercihi kaydet',
468
490
  'gitPreferences.saved': 'Bu dal şimdi proje varsayılanı olarak kaydedilir; ekipteki herkes onu görür.',
469
491
  'gitPreferences.unchanged': 'Hiçbir şey değişmez. Bu rol olduğu gibi kalır, sonra yeniden sorulabilir.',
492
+ 'gitlabToken.address': 'GitLab adresi',
493
+ 'gitlabToken.addressHint': "GitLab'ı tarayıcıda açtığın adres, örneğin https://gitlab.example.com. GitLab bir yol altında çalışıyorsa yolu da ekle.",
494
+ 'gitlabToken.certificate': 'Bu bilgisayar {address} adresinin sertifikasına güvenmiyor. Şirketin kendi sertifikasını kullanıyorsa NODE_EXTRA_CA_CERTS ortam değişkenini o sertifika dosyasına ayarla ve kod ajanını yeniden başlat.',
495
+ 'gitlabToken.expired': 'Bu bağlantının süresi dolmuş ya da daha önce kullanılmış. Kod ajanından yeni bir bağlantı iste.',
496
+ 'gitlabToken.failed': 'İstek işlenemedi. Biraz sonra tekrar dene.',
497
+ 'gitlabToken.gitlabError': 'GitLab hatayla cevap verdi ({status}); token kaydedilmedi. Biraz sonra tekrar dene.',
498
+ 'gitlabToken.intro': "Engineering Memory bu GitLab'daki merge request'lerini bu bilgisayardan açar ve takip eder. Token bu bilgisayarda, işletim sisteminin koruduğu kimlik bilgisi deposunda saklanır; Engineering Memory'nin sunucusuna da kod ajanına da gönderilmez.",
499
+ 'gitlabToken.invalidAddress': 'Adresi https://sunucu biçiminde gir; GitLab bir yol altında çalışıyorsa yolu da ekle. Düz http yalnızca bu bilgisayardaki bir GitLab için geçerli; IP numarasıyla verilen bir adres kullanılamaz.',
500
+ 'gitlabToken.missingToken': "Token'ı gir.",
501
+ 'gitlabToken.noApiScope': "GitLab token'ı kabul etti ama token'ın API erişimi yok. api yetkili bir token oluştur; yalnızca takip için read_api yeterli.",
502
+ 'gitlabToken.notGitLab': "{address} GitLab gibi cevap vermiyor. Adresi, GitLab'ın çalıştığı yol dahil kontrol et.",
503
+ 'gitlabToken.redirected': "{address} yönlendirmeyle cevap verdi; büyük olasılıkla GitLab'ın kendi adresi değil. https'i ve GitLab'ın çalıştığı yolu kontrol et.",
504
+ 'gitlabToken.rejected': "GitLab bu token'ı kabul etmedi. Tamamen kopyalandığını, süresinin dolmadığını ve iptal edilmediğini kontrol et.",
505
+ 'gitlabToken.remove': "Kayıtlı token'ı sil",
506
+ 'gitlabToken.removed': 'Bu GitLab adresi için bu bilgisayarda artık kayıtlı bir token yok. Bu sayfayı kapatabilirsin.',
507
+ 'gitlabToken.save': 'Kontrol et ve kaydet',
508
+ 'gitlabToken.saved': "Kaydedildi. GitLab token'ı kabul etti. Bu sayfayı kapatıp kod ajanına dönebilirsin.",
509
+ 'gitlabToken.savedReadOnly': "Kaydedildi. GitLab token'ı kabul etti ama bu token yalnızca okuyabiliyor: Engineering Memory merge request'leri takip edebilir, açamaz. Açabilmesi için api yetkili bir token kaydet.",
510
+ 'gitlabToken.title': "Engineering Memory için GitLab token'ı",
511
+ 'gitlabToken.token': "Kişisel erişim token'ı (personal access token)",
512
+ 'gitlabToken.tokenHint': "GitLab'da Preferences > Access tokens bölümünden oluşturabilirsin. api yetkisiyle Engineering Memory merge request açabilir; yalnızca takip için read_api yeterli.",
513
+ 'gitlabToken.unreachable': "Bu bilgisayar {address} adresine ulaşamıyor. GitLab'a yalnızca şirket ağından ulaşılıyorsa ağa bağlan (örneğin VPN ile) ve tekrar dene.",
470
514
  'inspection.defer': 'Şimdilik başlatma',
471
515
  'inspection.deferDetail': 'İnceleme başlamaz, hiçbir şey yazılmaz. Cevabın kaydedilir; bu kaynağı sonra incelemek yeni bir istek olur.',
472
516
  'inspection.example': 'İnceleme bitince her ekran, servis ve akış için birer hafıza kaydı önerilir; hangilerinin yayınlanacağına sen karar verirsin.',
@@ -66,6 +66,9 @@ const recordSchema = z.object({
66
66
  });
67
67
  export function registerDeliveryTools(server, service) {
68
68
  registerTaskDelivery(server, service);
69
+ registerTaskReviewRequest(server, service);
70
+ registerTaskOpenReviewRequest(server, service);
71
+ registerGitLabToken(server, service);
69
72
  server.registerTool('task.close', {
70
73
  description: 'Close the verified task, then open its durable mode-governed Git delivery selector in the same call. Memory publication is separate. Cancellation/feedback leave delivery pending; retry identical input to resume. PR/MR is one review-request concept, not an instruction to merge. No Git action is performed by this tool. Read-only tasks need no delivery. Supply complete copy when the product text catalogue lacks the conversation language. If the user already explicitly chose Git delivery in their own message, deliveryInstruction preserves that choice with its exact relevant excerpt; it is an agent-reported instruction, never a native answer or a substitute for missing user consent. Do not use it for agent decisions or memory-publication approval.',
71
74
  inputSchema: z.object({
@@ -507,6 +510,88 @@ function registerTaskDelivery(server, service) {
507
510
  });
508
511
  });
509
512
  }
513
+ function registerTaskReviewRequest(server, service) {
514
+ server.registerTool('task.review_request', {
515
+ description: "Record the pull request (GitHub) or merge request (GitLab) a closed task's changes went into, and its state: draft, ready, merged or closed. Call it with the link copied from the provider as soon as the pull request is opened, and again when the user says it became ready, was merged or was closed. Engineering Memory then moves the task's work item as the project's workflow rules say, for example to PR when every pull request of the work item is ready and to READY FOR QA when they are all merged. It works from any chat, also for a delivery session.entry lists. What is recorded is what was reported: Engineering Memory does not check it with the provider, and performs no Git or provider action.",
516
+ inputSchema: z.strictObject({
517
+ projectId: z.string().uuid(),
518
+ taskId: z.string().uuid(),
519
+ url: z.string().trim().min(1).max(500),
520
+ state: z.enum(['draft', 'ready', 'merged', 'closed']),
521
+ }),
522
+ }, async (input) => {
523
+ const result = await service.taskReviewRequest(input);
524
+ if (!result.ok)
525
+ return output(result);
526
+ const recorded = result.data;
527
+ return output({
528
+ ok: true,
529
+ data: {
530
+ ...result.data,
531
+ nextAction: recorded.queued
532
+ ? 'Engineering Memory is unreachable, so this report is queued. It is recorded, and the work item moves as the rules say, when Engineering Memory is reachable again.'
533
+ : (recorded.data?.reviewEvent?.nextAction ??
534
+ (recorded.data?.reviewEvent
535
+ ? 'The pull request is recorded; the work item stays where it is.'
536
+ : 'The pull request is recorded. The work item does not move yet: other work on it is still open or undelivered, or the task has no active work item.')),
537
+ },
538
+ });
539
+ });
540
+ }
541
+ function registerTaskOpenReviewRequest(server, service) {
542
+ server.registerTool('task.open_review_request', {
543
+ description: "Open the merge request of a delivered task on GitLab from this computer and record it, in one call. It works for a GitLab only this computer's network reaches, with the user's own token saved on this computer through gitlab.token. The target branch and draft come from the delivery answer unless given here. The branch must already be on GitLab with the delivered commit. An open merge request of the branch is reused, never opened twice. When it refuses, it says why and what works instead; opening the merge request in GitLab by hand and recording it with task.review_request always works.",
544
+ inputSchema: z.strictObject({
545
+ projectId: z.string().uuid(),
546
+ taskId: z.string().uuid(),
547
+ repoRoot: z.string().min(1).optional(),
548
+ targetBranch: z.string().trim().min(1).max(240).optional(),
549
+ draft: z.boolean().optional(),
550
+ title: z
551
+ .string()
552
+ .trim()
553
+ .min(1)
554
+ .max(200)
555
+ .regex(/^[^\r\n]+$/)
556
+ .optional(),
557
+ description: z.string().trim().min(1).max(4000).optional(),
558
+ }),
559
+ }, async (input) => {
560
+ const result = await service.taskOpenReviewRequest(input);
561
+ if (!result.ok)
562
+ return output(result);
563
+ const data = result.data;
564
+ const opened = data.mergeRequest;
565
+ const said = opened
566
+ ? `${opened.reused ? 'The branch already had an open merge request, and it is used' : 'The merge request is opened'}: ${opened.url} (${opened.state}, into ${opened.targetBranch}).`
567
+ : '';
568
+ return output({
569
+ ok: true,
570
+ data: {
571
+ ...data,
572
+ nextAction: data.alreadyRecorded
573
+ ? `This delivery already has its merge request recorded: ${data.alreadyRecorded.url} (${data.alreadyRecorded.state}). Nothing was opened. When the user says its state changed, record that with task.review_request.`
574
+ : data.reportRefused
575
+ ? `${said} Recording it in Engineering Memory was refused: ${data.reportRefused} Tell the user; once the cause is fixed, record it with task.review_request.`
576
+ : data.report?.queued
577
+ ? `${said} Engineering Memory is unreachable, so recording it is queued; it is recorded, and the work item moves, when Engineering Memory is reachable again.`
578
+ : `${said} ${data.report?.data?.reviewEvent?.nextAction ??
579
+ (data.report?.data?.reviewEvent
580
+ ? 'It is recorded; the work item stays where it is.'
581
+ : 'It is recorded. The work item does not move yet: other work on it is still open or undelivered, or the task has no active work item.')}`,
582
+ },
583
+ });
584
+ });
585
+ }
586
+ function registerGitLabToken(server, service) {
587
+ server.registerTool('gitlab.token', {
588
+ description: "Give the user a page on this computer where they save their own GitLab personal access token, so task.open_review_request can open merge requests and Engineering Memory can follow them on a GitLab only this computer's network reaches. The token is typed only on that page: it never passes through the chat, this agent or Engineering Memory's server, and it is kept in this computer's credential store for the signed-in account. Never open or fill in the page yourself. The same page removes a saved token.",
589
+ inputSchema: z.strictObject({
590
+ repoRoot: z.string().min(1).optional(),
591
+ language: languageTag.optional(),
592
+ }),
593
+ }, async (input) => output(await service.gitlabToken(input)));
594
+ }
510
595
  function askPullRequestBase(server, service, context, input, language, copy, owner) {
511
596
  return askQuestionnaire(server, service, {
512
597
  repoRoot: input.repoRoot,
@@ -78,6 +78,9 @@ export const toolAnnotations = {
78
78
  'task.verify': write,
79
79
  'task.close': write,
80
80
  'task.delivery': write,
81
+ 'task.review_request': repeatableWrite,
82
+ 'task.open_review_request': { ...outward, idempotentHint: true },
83
+ 'gitlab.token': outward,
81
84
  'task.abandon': destructive,
82
85
  'task.branch': outward, // fetches the chosen base from the Git remote
83
86
  'architecture.plan': read,
@@ -117,6 +117,9 @@ export const engineeringMemoryToolNames = [
117
117
  'task.verify',
118
118
  'task.close',
119
119
  'task.delivery',
120
+ 'task.review_request',
121
+ 'task.open_review_request',
122
+ 'gitlab.token',
120
123
  'task.abandon',
121
124
  'task.branch',
122
125
  'architecture.plan',
@@ -0,0 +1,226 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import * as z from 'zod/v4';
4
+ import { copies, escapeHtml, format, textDirection } from '../runtime/texts.js';
5
+ import { GitLabError, gitLabAddress } from './gitlab.js';
6
+ const text = z.string().min(1);
7
+ const pageWording = z.strictObject({
8
+ title: text,
9
+ intro: text,
10
+ address: text,
11
+ addressHint: text,
12
+ token: text,
13
+ tokenHint: text,
14
+ save: text,
15
+ remove: text,
16
+ saved: text,
17
+ savedReadOnly: text,
18
+ removed: text,
19
+ rejected: text,
20
+ noApiScope: text,
21
+ unreachable: text,
22
+ certificate: text,
23
+ redirected: text,
24
+ notGitLab: text,
25
+ invalidAddress: text,
26
+ missingToken: text,
27
+ gitlabError: text,
28
+ failed: text,
29
+ expired: text,
30
+ });
31
+ const maximumBody = 8 * 1024;
32
+ export class GitLabTokenPage {
33
+ tokens;
34
+ gitlab;
35
+ language;
36
+ server = null;
37
+ page = null;
38
+ timer = null;
39
+ queue = Promise.resolve();
40
+ constructor(tokens, gitlab, language) {
41
+ this.tokens = tokens;
42
+ this.gitlab = gitlab;
43
+ this.language = language;
44
+ }
45
+ async open(target) {
46
+ await this.close();
47
+ const server = createServer((request, response) => {
48
+ this.queue = this.queue.then(() => this.handle(request, response)).catch(() => undefined);
49
+ });
50
+ await new Promise((resolvePromise, reject) => {
51
+ server.once('error', reject);
52
+ server.listen(0, '127.0.0.1', resolvePromise);
53
+ });
54
+ server.unref();
55
+ const port = server.address().port;
56
+ const expiresAt = Date.now() + 10 * 60 * 1000;
57
+ this.server = server;
58
+ this.page = { nonce: randomBytes(32).toString('base64url'), port, ...target, expiresAt };
59
+ this.timer = setTimeout(() => void this.close(), expiresAt - Date.now());
60
+ this.timer.unref();
61
+ return {
62
+ url: `http://127.0.0.1:${port}/${this.page.nonce}`,
63
+ expiresAt: new Date(expiresAt).toISOString(),
64
+ };
65
+ }
66
+ async close() {
67
+ const server = this.server;
68
+ this.server = null;
69
+ this.page = null;
70
+ if (this.timer)
71
+ clearTimeout(this.timer);
72
+ this.timer = null;
73
+ if (!server)
74
+ return;
75
+ await new Promise((resolvePromise) => {
76
+ server.close(() => resolvePromise());
77
+ server.closeAllConnections();
78
+ });
79
+ }
80
+ async handle(request, response) {
81
+ const page = this.page;
82
+ try {
83
+ const own = page ? `127.0.0.1:${page.port}` : null;
84
+ if (!page || request.headers.host !== own)
85
+ return this.send(response, 421, await this.notice('expired'));
86
+ if (request.url !== `/${page.nonce}`)
87
+ return this.send(response, 404, await this.notice('expired'));
88
+ if (Date.now() >= page.expiresAt)
89
+ return this.finish(response, await this.notice('expired'), 410);
90
+ if (request.method === 'GET')
91
+ return this.send(response, 200, await this.form(page, page.address ?? ''));
92
+ if (request.method !== 'POST')
93
+ return this.send(response, 405, '', { Allow: 'GET, POST' });
94
+ if (request.headers.origin !== undefined && request.headers.origin !== `http://${own}`)
95
+ return this.send(response, 403, await this.notice('expired'));
96
+ if (!/^application\/x-www-form-urlencoded(;|$)/i.test(request.headers['content-type'] ?? ''))
97
+ return this.send(response, 415, await this.notice('failed'));
98
+ const body = await readBody(request);
99
+ if (body === null)
100
+ return this.send(response, 413, await this.notice('failed'));
101
+ const fields = new URLSearchParams(body);
102
+ const typed = fields.get('address') ?? '';
103
+ const address = gitLabAddress(typed);
104
+ if (!address)
105
+ return this.send(response, 400, await this.form(page, typed, 'invalidAddress'));
106
+ if (fields.get('action') === 'remove') {
107
+ await this.tokens.remove(address);
108
+ return this.finish(response, await this.notice('removed'));
109
+ }
110
+ const token = (fields.get('token') ?? '').trim();
111
+ if (!token || token.length > 1024 || /[\s\u0000-\u001f\u007f]/.test(token))
112
+ return this.send(response, 400, await this.form(page, address, 'missingToken'));
113
+ try {
114
+ await this.gitlab.user(address, token);
115
+ const scopes = await this.gitlab.scopes(address, token);
116
+ await this.tokens.save(page.host ?? new URL(address).host, address, token);
117
+ return this.finish(response, await this.notice(scopes && !scopes.includes('api') ? 'savedReadOnly' : 'saved'));
118
+ }
119
+ catch (error) {
120
+ if (!(error instanceof GitLabError))
121
+ throw error;
122
+ return this.send(response, 400, await this.form(page, address, refusal(error), error));
123
+ }
124
+ }
125
+ catch {
126
+ return this.send(response, 500, await this.notice('failed'));
127
+ }
128
+ }
129
+ async finish(response, html, status = 200) {
130
+ this.page = null;
131
+ const server = this.server;
132
+ response.once('finish', () => {
133
+ if (this.server === server)
134
+ void this.close();
135
+ });
136
+ this.send(response, status, html);
137
+ }
138
+ send(response, status, html, headers = {}) {
139
+ response.writeHead(status, {
140
+ 'Content-Type': 'text/html; charset=utf-8',
141
+ 'Cache-Control': 'no-store',
142
+ 'Referrer-Policy': 'no-referrer',
143
+ 'X-Content-Type-Options': 'nosniff',
144
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
145
+ ...headers,
146
+ });
147
+ response.end(html);
148
+ }
149
+ async wording() {
150
+ const told = await this.language().catch(() => undefined);
151
+ return copies(pageWording, 'gitlabToken', told)[0];
152
+ }
153
+ async notice(key) {
154
+ const { language, copy } = await this.wording();
155
+ return document(language, copy.title, `<p>${escapeHtml(copy[key])}</p>`);
156
+ }
157
+ async form(page, address, problem, error) {
158
+ const { language, copy } = await this.wording();
159
+ const shown = problem
160
+ ? format(copy[problem], language, {
161
+ address: error?.address ?? address,
162
+ status: error?.status ?? '-',
163
+ })
164
+ : null;
165
+ return document(language, copy.title, `<p>${escapeHtml(copy.intro)}</p>` +
166
+ (shown ? `<p class="problem" role="alert">${escapeHtml(shown)}</p>` : '') +
167
+ `<form method="post" action="/${page.nonce}" autocomplete="off">` +
168
+ `<label for="address">${escapeHtml(copy.address)}</label>` +
169
+ `<input id="address" name="address" type="url" required value="${escapeHtml(address)}">` +
170
+ `<p class="hint">${escapeHtml(copy.addressHint)}</p>` +
171
+ `<label for="token">${escapeHtml(copy.token)}</label>` +
172
+ `<input id="token" name="token" type="password" autocomplete="new-password" spellcheck="false">` +
173
+ `<p class="hint">${escapeHtml(copy.tokenHint)}</p>` +
174
+ `<div class="actions"><button name="action" value="save">${escapeHtml(copy.save)}</button>` +
175
+ `<button name="action" value="remove" class="secondary" formnovalidate>${escapeHtml(copy.remove)}</button></div>` +
176
+ `</form>`);
177
+ }
178
+ }
179
+ function refusal(error) {
180
+ switch (error.failure) {
181
+ case 'token_rejected':
182
+ return 'rejected';
183
+ case 'not_allowed':
184
+ return 'noApiScope';
185
+ case 'unreachable':
186
+ return 'unreachable';
187
+ case 'untrusted_certificate':
188
+ return 'certificate';
189
+ case 'redirected':
190
+ return 'redirected';
191
+ case 'not_found':
192
+ case 'unexpected_answer':
193
+ return 'notGitLab';
194
+ default:
195
+ return 'gitlabError';
196
+ }
197
+ }
198
+ function document(language, title, body) {
199
+ return (`<!doctype html><html lang="${escapeHtml(language)}" dir="${textDirection(language)}"><head>` +
200
+ '<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">' +
201
+ `<title>${escapeHtml(title)}</title><style>` +
202
+ 'body{font:16px/1.5 system-ui,sans-serif;margin:0;padding:24px 16px;background:#f6f7f9;color:#1d2330}' +
203
+ 'main{max-width:560px;margin:0 auto}h1{font-size:22px;line-height:1.3}' +
204
+ 'label{display:block;font-weight:600;margin-top:20px}' +
205
+ 'input{box-sizing:border-box;width:100%;padding:10px;margin-top:6px;font:inherit;border:1px solid #9aa3b2;border-radius:6px;background:#fff;color:inherit}' +
206
+ '.hint{margin:6px 0 0;font-size:14px;color:#4b5565}.problem{padding:10px 12px;border-radius:6px;background:#fdecec;color:#8a1c1c}' +
207
+ '.actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:24px}' +
208
+ 'button{font:inherit;padding:10px 16px;border-radius:6px;border:1px solid #2952cc;background:#2952cc;color:#fff;cursor:pointer}' +
209
+ 'button.secondary{background:transparent;color:#2952cc}' +
210
+ '@media (prefers-color-scheme:dark){body{background:#14171c;color:#e6e9ef}input{background:#1d2129;border-color:#4b5565}.hint{color:#aab2c0}.problem{background:#3a1d1d;color:#f3b4b4}button{background:#5b7ef0;border-color:#5b7ef0}button.secondary{color:#9db3ff}}' +
211
+ `</style></head><body><main><h1>${escapeHtml(title)}</h1>${body}</main></body></html>`);
212
+ }
213
+ function readBody(request) {
214
+ return new Promise((resolvePromise, reject) => {
215
+ const chunks = [];
216
+ let size = 0;
217
+ request.on('data', (chunk) => {
218
+ size += chunk.length;
219
+ if (size <= maximumBody)
220
+ chunks.push(chunk);
221
+ });
222
+ request.once('end', () => resolvePromise(size > maximumBody ? null : Buffer.concat(chunks).toString('utf8')));
223
+ request.once('error', reject);
224
+ });
225
+ }
226
+ //# sourceMappingURL=gitlab-token-page.js.map