@rse/ase 0.9.46 → 0.9.48

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/dst/ase-kv.js CHANGED
@@ -30,10 +30,10 @@ export class KV {
30
30
  KV.validateKey(key);
31
31
  return KV.store.has(key);
32
32
  }
33
- /* get a value by key; returns undefined if no value is stored */
33
+ /* get a deep copy of a value by key; returns undefined if no value is stored */
34
34
  static get(key) {
35
35
  KV.validateKey(key);
36
- return KV.store.get(key);
36
+ return structuredClone(KV.store.get(key));
37
37
  }
38
38
  /* set a value under the given key; overwrites any existing value */
39
39
  static set(key, val) {
@@ -72,7 +72,48 @@ export class KV {
72
72
  for (const [k, v] of snap)
73
73
  KV.store.set(k, v);
74
74
  }
75
+ /* execute a single key/value command and return its result text */
76
+ static execute(c) {
77
+ if (c.command === "clear")
78
+ return `OK: removed ${KV.clear(c.prefix)} key(s)`;
79
+ else if (c.command === "set") {
80
+ if (c.key === undefined)
81
+ throw new Error("missing `key`");
82
+ if (c.val === undefined)
83
+ throw new Error("missing `val`");
84
+ KV.set(c.key, c.val);
85
+ return `OK: stored key "${c.key}"`;
86
+ }
87
+ else if (c.command === "get") {
88
+ if (c.key === undefined)
89
+ throw new Error("missing `key`");
90
+ if (!KV.has(c.key))
91
+ return "";
92
+ const val = KV.get(c.key);
93
+ return val === undefined ? "" : JSON.stringify(val);
94
+ }
95
+ else if (c.command === "delete") {
96
+ if (c.key === undefined)
97
+ throw new Error("missing `key`");
98
+ return KV.delete(c.key) ?
99
+ `OK: removed key "${c.key}"` :
100
+ `WARNING: no key "${c.key}" to remove`;
101
+ }
102
+ else
103
+ throw new Error(`invalid command "${String(c.command)}"`);
104
+ }
75
105
  }
106
+ /* extract the message of a caught error */
107
+ const errorMessage = (err) => err instanceof Error ? err.message : String(err);
108
+ /* execute a single key/value command and render it as an MCP tool result */
109
+ const mcpToolExec = (c) => {
110
+ try {
111
+ return { content: [{ type: "text", text: KV.execute(c) }] };
112
+ }
113
+ catch (err) {
114
+ return { isError: true, content: [{ type: "text", text: `ERROR: ${errorMessage(err)}` }] };
115
+ }
116
+ };
76
117
  /* MCP registration entry point for in-memory key/value tools */
77
118
  export class KVMCP {
78
119
  register(mcp) {
@@ -85,19 +126,7 @@ export class KVMCP {
85
126
  key: z.string()
86
127
  .describe("key identifier (non-empty, no whitespace-only, no control characters, up to 1024 characters)")
87
128
  }
88
- }, async (args) => {
89
- try {
90
- if (!KV.has(args.key))
91
- return { content: [{ type: "text", text: "" }] };
92
- const val = KV.get(args.key);
93
- const text = val === undefined ? "" : JSON.stringify(val);
94
- return { content: [{ type: "text", text }] };
95
- }
96
- catch (err) {
97
- const message = err instanceof Error ? err.message : String(err);
98
- return { isError: true, content: [{ type: "text", text: `ERROR: ${message}` }] };
99
- }
100
- });
129
+ }, async (args) => mcpToolExec({ command: "get", key: args.key }));
101
130
  /* key/value set */
102
131
  mcp.registerTool("ase_kv_set", {
103
132
  title: "ASE key/value set",
@@ -110,16 +139,7 @@ export class KVMCP {
110
139
  val: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.unknown()), z.record(z.string(), z.unknown())])
111
140
  .describe("arbitrary JSON-compatible value to store under `key`")
112
141
  }
113
- }, async (args) => {
114
- try {
115
- KV.set(args.key, args.val);
116
- return { content: [{ type: "text", text: `OK: stored key "${args.key}"` }] };
117
- }
118
- catch (err) {
119
- const message = err instanceof Error ? err.message : String(err);
120
- return { isError: true, content: [{ type: "text", text: `ERROR: ${message}` }] };
121
- }
122
- });
142
+ }, async (args) => mcpToolExec({ command: "set", key: args.key, val: args.val }));
123
143
  /* key/value clear */
124
144
  mcp.registerTool("ase_kv_clear", {
125
145
  title: "ASE key/value clear",
@@ -131,16 +151,7 @@ export class KVMCP {
131
151
  prefix: z.string().optional()
132
152
  .describe("if given, only remove keys starting with this prefix (otherwise remove all keys)")
133
153
  }
134
- }, async (args) => {
135
- try {
136
- const n = KV.clear(args.prefix);
137
- return { content: [{ type: "text", text: `OK: removed ${n} key(s)` }] };
138
- }
139
- catch (err) {
140
- const message = err instanceof Error ? err.message : String(err);
141
- return { isError: true, content: [{ type: "text", text: `ERROR: ${message}` }] };
142
- }
143
- });
154
+ }, async (args) => mcpToolExec({ command: "clear", prefix: args.prefix }));
144
155
  /* key/value delete */
145
156
  mcp.registerTool("ase_kv_delete", {
146
157
  title: "ASE key/value delete",
@@ -150,19 +161,7 @@ export class KVMCP {
150
161
  key: z.string()
151
162
  .describe("key identifier (non-empty, no whitespace-only, no control characters, up to 1024 characters)")
152
163
  }
153
- }, async (args) => {
154
- try {
155
- const removed = KV.delete(args.key);
156
- const msg = removed ?
157
- `OK: removed key "${args.key}"` :
158
- `WARNING: no key "${args.key}" to remove`;
159
- return { content: [{ type: "text", text: msg }] };
160
- }
161
- catch (err) {
162
- const message = err instanceof Error ? err.message : String(err);
163
- return { isError: true, content: [{ type: "text", text: `ERROR: ${message}` }] };
164
- }
165
- });
164
+ }, async (args) => mcpToolExec({ command: "delete", key: args.key }));
166
165
  /* key/value batch */
167
166
  mcp.registerTool("ase_kv_batch", {
168
167
  title: "ASE key/value batch",
@@ -199,39 +198,10 @@ export class KVMCP {
199
198
  const snapshot = tx ? KV.snapshot() : null;
200
199
  for (const c of args.commands) {
201
200
  try {
202
- if (c.command === "clear") {
203
- const n = KV.clear(c.prefix);
204
- results.push(`OK: removed ${n} key(s)`);
205
- }
206
- else if (c.command === "set") {
207
- if (c.key === undefined)
208
- throw new Error("missing `key`");
209
- if (c.val === undefined)
210
- throw new Error("missing `val`");
211
- KV.set(c.key, c.val);
212
- results.push(`OK: stored key "${c.key}"`);
213
- }
214
- else if (c.command === "get") {
215
- if (c.key === undefined)
216
- throw new Error("missing `key`");
217
- if (!KV.has(c.key))
218
- results.push("");
219
- else {
220
- const val = KV.get(c.key);
221
- results.push(val === undefined ? "" : JSON.stringify(val));
222
- }
223
- }
224
- else if (c.command === "delete") {
225
- if (c.key === undefined)
226
- throw new Error("missing `key`");
227
- const removed = KV.delete(c.key);
228
- results.push(removed ?
229
- `OK: removed key "${c.key}"` :
230
- `WARNING: no key "${c.key}" to remove`);
231
- }
201
+ results.push(KV.execute(c));
232
202
  }
233
203
  catch (err) {
234
- const message = err instanceof Error ? err.message : String(err);
204
+ const message = errorMessage(err);
235
205
  if (tx) {
236
206
  if (snapshot !== null)
237
207
  KV.restore(snapshot);
package/dst/ase-log.js CHANGED
@@ -24,11 +24,7 @@ export default class Log {
24
24
  this._logFile = _logFile;
25
25
  }
26
26
  async init() {
27
- /* log messages */
28
- const idx = levels.findIndex((l) => l.name === this._logLevel);
29
- if (idx === -1)
30
- throw new RangeError(`invalid log level "${this._logLevel}" (expected one of: ${levels.map((l) => l.name).join(", ")})`);
31
- this.logLevelIdx = idx;
27
+ this.logLevel(this._logLevel);
32
28
  if (this._logFile !== "-")
33
29
  this.stream = this.openStream(this._logFile);
34
30
  }
@@ -73,4 +69,16 @@ export default class Log {
73
69
  this.stream.write(line);
74
70
  }
75
71
  }
72
+ /* close the log file stream, flushing all pending writes */
73
+ async close() {
74
+ if (this.stream === null)
75
+ return;
76
+ const stream = this.stream;
77
+ this.stream = null;
78
+ await new Promise((resolve) => {
79
+ stream.end(() => {
80
+ resolve();
81
+ });
82
+ });
83
+ }
76
84
  }
@@ -69,6 +69,16 @@ export class Markdown {
69
69
  n++;
70
70
  return n;
71
71
  }
72
+ /* test whether the newline at offset pos starts a paragraph break,
73
+ i.e. whether it is followed by a blank line or the end of the text */
74
+ static isParagraphBreak(text, pos) {
75
+ let n = pos + 1;
76
+ while (n < text.length && (text[n] === " " || text[n] === "\t"))
77
+ n++;
78
+ if (text[n] === "\r")
79
+ n++;
80
+ return n >= text.length || text[n] === "\n";
81
+ }
72
82
  /* PASS 1 of rewrite(): rewrite inline code spans that carry
73
83
  backslash-escaped backticks (`\``) into CommonMark-correct spans */
74
84
  static rewriteEscapedSpans(text) {
@@ -118,7 +128,7 @@ export class Markdown {
118
128
  k += runLen;
119
129
  continue;
120
130
  }
121
- if (c === "\n" && /^[ \t]*\r?(?:\n|$)/.test(text.slice(k + 1)))
131
+ if (c === "\n" && Markdown.isParagraphBreak(text, k))
122
132
  /* a code span never crosses a paragraph break */
123
133
  break;
124
134
  inner += c;
@@ -185,7 +195,7 @@ export class Markdown {
185
195
  closes = true;
186
196
  p += r;
187
197
  }
188
- else if (text[p] === "\n" && /^[ \t]*\r?(?:\n|$)/.test(text.slice(p + 1)))
198
+ else if (text[p] === "\n" && Markdown.isParagraphBreak(text, p))
189
199
  /* a code span never crosses a paragraph break */
190
200
  break;
191
201
  else
package/dst/ase-mcp.js CHANGED
@@ -23,7 +23,7 @@ export default class MCPCommand {
23
23
  let ctx = this.loadContext();
24
24
  /* fast path: already running */
25
25
  if (ctx.port !== null) {
26
- const match = await probe(ctx.port, ctx.projectId);
26
+ const match = await probe(ctx.port, ctx.projectId).catch(() => null);
27
27
  if (match === true)
28
28
  return { projectId: ctx.projectId, port: ctx.port };
29
29
  }
@@ -94,8 +94,11 @@ export default class MCPCommand {
94
94
  return;
95
95
  triggerReconnect("http connection lost");
96
96
  };
97
+ /* activate the connection and flush buffered messages */
97
98
  await next.start();
98
99
  client = next;
100
+ for (const msg of pending.splice(0, pending.length))
101
+ sendToClient(msg);
99
102
  };
100
103
  /* reconnect loop: restart service if needed, then reconnect client */
101
104
  const reconnect = async (attempt = 0) => {
@@ -132,12 +135,26 @@ export default class MCPCommand {
132
135
  this.log.write("warning", `mcp: ${reason} — reconnecting`);
133
136
  reconnect(0).catch(() => { });
134
137
  };
135
- /* wire stdio server */
136
- server.onmessage = (msg) => {
137
- client?.send(msg).catch((err) => {
138
+ /* bounded buffer for messages arriving while no HTTP client is connected */
139
+ const MAX_PENDING = 100;
140
+ const pending = [];
141
+ /* forward a message to the HTTP client, buffering it while the client
142
+ is disconnected so requests survive a reconnect window */
143
+ const sendToClient = (msg) => {
144
+ if (client === null) {
145
+ if (pending.length >= MAX_PENDING) {
146
+ this.log.write("warning", "mcp: pending queue overflow, dropping oldest message");
147
+ pending.shift();
148
+ }
149
+ pending.push(msg);
150
+ return;
151
+ }
152
+ client.send(msg).catch((err) => {
138
153
  this.log.write("error", `mcp: http send: ${this.asError(err).message}`);
139
154
  });
140
155
  };
156
+ /* wire stdio server */
157
+ server.onmessage = sendToClient;
141
158
  server.onerror = (err) => {
142
159
  this.log.write("error", `mcp: stdio: ${err.message}`);
143
160
  };
package/dst/ase-meta.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import path from "node:path";
7
7
  import fs from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
9
- import { writeStdout } from "./ase-stdout.js";
9
+ import { writeStdout } from "./ase-stdio.js";
10
10
  /* reusable functionality: resolve and read plugin "meta/" files */
11
11
  export class Meta {
12
12
  /* determine the plugin "meta/" directory; the build process copies
@@ -20,9 +20,7 @@ export class Persona {
20
20
  if (val === undefined)
21
21
  return "engineer";
22
22
  const style = String(isScalar(val) ? val.value : val);
23
- if (!Persona.styles.includes(style))
24
- return "engineer";
25
- return style;
23
+ return Persona.styles.find((s) => s === style) ?? "engineer";
26
24
  }
27
25
  /* set the persona style on the strongest scope of an optional session */
28
26
  static set(log, style, session) {
@@ -477,6 +477,16 @@ export default class ServiceCommand {
477
477
  }
478
478
  throw lastErr;
479
479
  }
480
+ /* build the "POST /command" request options for a command token */
481
+ commandRequest(cmd, timeoutMs) {
482
+ return {
483
+ method: "POST",
484
+ headers: { "Content-Type": "application/json" },
485
+ body: { command: cmd },
486
+ signal: AbortSignal.timeout(timeoutMs),
487
+ ignoreResponseError: true
488
+ };
489
+ }
480
490
  /* status flow: report whether the service is running */
481
491
  async doStatus() {
482
492
  const ctx = this.loadContext();
@@ -484,15 +494,9 @@ export default class ServiceCommand {
484
494
  process.stdout.write("service: not running (no port configured)\n");
485
495
  return 1;
486
496
  }
487
- const match = await probe(ctx.port, ctx.projectId);
497
+ const match = await probe(ctx.port, ctx.projectId).catch(() => null);
488
498
  if (match === true) {
489
- const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, {
490
- method: "POST",
491
- headers: { "Content-Type": "application/json" },
492
- body: { command: "status" },
493
- signal: AbortSignal.timeout(2000),
494
- ignoreResponseError: true
495
- });
499
+ const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, this.commandRequest("status", 2000));
496
500
  const d = r._data;
497
501
  const uptimeMs = typeof d?.uptimeMs === "number" ? d.uptimeMs : 0;
498
502
  const uptime = prettyMs(uptimeMs, { verbose: true });
@@ -509,30 +513,16 @@ export default class ServiceCommand {
509
513
  /* send command: POST /command with the arbitrary cmd token */
510
514
  async doSend(cmd) {
511
515
  let ctx = this.loadContext();
512
- if (ctx.port === null) {
513
- await this.doStart();
514
- ctx = this.loadContext();
515
- if (ctx.port === null)
516
- throw new Error("service not running (no port configured after auto-start)");
517
- }
518
- let match = await probe(ctx.port, ctx.projectId);
519
- if (match !== true) {
516
+ if (ctx.port === null || await probe(ctx.port, ctx.projectId).catch(() => null) !== true) {
517
+ /* auto-start the service once, then re-check */
520
518
  await this.doStart();
521
519
  ctx = this.loadContext();
522
520
  if (ctx.port === null)
523
521
  throw new Error("service not running (no port configured after auto-start)");
524
- match = await probe(ctx.port, ctx.projectId);
525
- if (match !== true)
522
+ if (await probe(ctx.port, ctx.projectId).catch(() => null) !== true)
526
523
  throw new Error(`service not responding on port ${ctx.port} after auto-start`);
527
524
  }
528
- const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, {
529
- method: "POST",
530
- headers: { "Content-Type": "application/json" },
531
- body: { command: cmd },
532
- signal: AbortSignal.timeout(5000),
533
- ignoreResponseError: true,
534
- responseType: "text"
535
- });
525
+ const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, { ...this.commandRequest(cmd, 5000), responseType: "text" });
536
526
  const body = typeof r._data === "string" ? r._data : JSON.stringify(r._data);
537
527
  process.stdout.write(body);
538
528
  if (!body.endsWith("\n"))
@@ -546,7 +536,7 @@ export default class ServiceCommand {
546
536
  this.log.write("info", "service: not running (no port configured)");
547
537
  return 0;
548
538
  }
549
- const match = await probe(ctx.port, ctx.projectId);
539
+ const match = await probe(ctx.port, ctx.projectId).catch(() => null);
550
540
  if (match === false) {
551
541
  this.log.write("info", `service: not running (port ${ctx.port} in use by foreign service)`);
552
542
  return 1;
package/dst/ase-setup.js CHANGED
@@ -63,27 +63,18 @@ export default class SetupCommand {
63
63
  path.join(prefix, "bin"),
64
64
  path.join(prefix, "lib", "node_modules")
65
65
  ];
66
+ const accessible = (dir, mode) => fs.access(dir, mode).then(() => true, () => false);
66
67
  for (const dir of candidates) {
67
- try {
68
- await fs.access(dir, fs.constants.W_OK);
69
- }
70
- catch {
71
- /* directory exists but not writable, or does not exist
72
- inside a non-writable parent: require sudo */
73
- try {
74
- await fs.access(dir, fs.constants.F_OK);
75
- return true;
76
- }
77
- catch {
78
- /* directory does not exist: check parent writability */
79
- try {
80
- await fs.access(path.dirname(dir), fs.constants.W_OK);
81
- }
82
- catch {
83
- return true;
84
- }
85
- }
86
- }
68
+ /* a writable directory needs no elevation at all */
69
+ if (await accessible(dir, fs.constants.W_OK))
70
+ continue;
71
+ /* the directory exists, but is not writable: require sudo */
72
+ if (await accessible(dir, fs.constants.F_OK))
73
+ return true;
74
+ /* the directory does not exist: require sudo unless it can
75
+ be created inside a writable parent directory */
76
+ if (!(await accessible(path.dirname(dir), fs.constants.W_OK)))
77
+ return true;
87
78
  }
88
79
  return false;
89
80
  }
@@ -110,25 +101,7 @@ export default class SetupCommand {
110
101
  for (let i = 0; i < retries; i++) {
111
102
  const final = (i === retries - 1);
112
103
  try {
113
- if (quiet) {
114
- const result = await execa(cmd, args, { stdio: "ignore", cwd, reject: false });
115
- if (typeof result.exitCode === "number" && result.exitCode !== 0) {
116
- if (!final) {
117
- this.log.write("info", `setup: attempt ${i + 1}/${retries} failed for "${cmd} ${args.join(" ")}" ` +
118
- `(exit code: ${result.exitCode}): retrying...`);
119
- await new Promise((resolve) => setTimeout(resolve, 1000));
120
- continue;
121
- }
122
- if (ignoreError !== undefined) {
123
- this.log.write("info", `setup: ${ignoreError} (skipped)`);
124
- return;
125
- }
126
- this.log.write("error", `setup: command failed: exit code: ${result.exitCode}`);
127
- throw new Error(`command "${cmd} ${args.join(" ")}" failed (exit code: ${result.exitCode})`);
128
- }
129
- return;
130
- }
131
- await execa(cmd, args, { stdio: "pipe", cwd });
104
+ await execa(cmd, args, { stdio: quiet ? "ignore" : "pipe", cwd });
132
105
  return;
133
106
  }
134
107
  catch (err) {
@@ -383,29 +356,13 @@ export default class SetupCommand {
383
356
  args.push(name, transport.url);
384
357
  }
385
358
  }
386
- else if (tool === "copilot") {
387
- /* GitHub Copilot CLI implies the stdio transport when the
388
- command is provided after "--"; only "http"/"sse" servers
389
- need an explicit "--transport" flag and take the URL as a
390
- positional argument */
391
- if (transport.type === "stdio") {
392
- args.push(name);
393
- for (const [key, val] of Object.entries(env))
394
- args.push("--env", `${key}=${val}`);
395
- args.push("--", ...transport.command);
396
- }
397
- else {
398
- args.push("--transport", "http");
399
- for (const [key, val] of Object.entries(transport.headers ?? {}))
400
- args.push("--header", `${key}: ${val}`);
401
- args.push(name, transport.url);
402
- }
403
- }
404
359
  else {
405
- /* OpenAI Codex CLI takes the server name as the first
406
- positional argument and implies the stdio transport when the
407
- command is provided after "--"; "http" servers take the URL
408
- via the "--url" option (with optional "--header" flags) */
360
+ /* the GitHub Copilot CLI and OpenAI Codex CLI both take the
361
+ server name as a positional argument and imply the stdio
362
+ transport when the command is provided after "--"; for "http"
363
+ servers the GitHub Copilot CLI needs an explicit
364
+ "--transport" flag and takes the URL positionally, while the
365
+ OpenAI Codex CLI takes it via the "--url" option */
409
366
  if (transport.type === "stdio") {
410
367
  args.push(name);
411
368
  for (const [key, val] of Object.entries(env))
@@ -413,9 +370,14 @@ export default class SetupCommand {
413
370
  args.push("--", ...transport.command);
414
371
  }
415
372
  else {
373
+ if (tool === "copilot")
374
+ args.push("--transport", "http");
416
375
  for (const [key, val] of Object.entries(transport.headers ?? {}))
417
376
  args.push("--header", `${key}: ${val}`);
418
- args.push(name, "--url", transport.url);
377
+ if (tool === "copilot")
378
+ args.push(name, transport.url);
379
+ else
380
+ args.push(name, "--url", transport.url);
419
381
  }
420
382
  }
421
383
  await this.run(toolSpecs[tool].cli, args);
@@ -684,24 +646,18 @@ export default class SetupCommand {
684
646
  AST, reproducing the exact whitespace tokens json-asty emits for a
685
647
  canonically 4-space-indented settings.json */
686
648
  statuslineBuildMember(root, command) {
687
- const strMember = (key, val, epilog) => {
688
- const k = root.create("string").set({ body: JSON.stringify(key), value: key, epilog: ": " });
689
- const v = root.create("string").set({ body: JSON.stringify(val), value: val });
690
- const m = root.create("member");
691
- if (epilog !== undefined)
692
- m.set({ epilog });
693
- return m.add(k, v);
694
- };
695
- const numMember = (key, val, epilog) => {
649
+ const member = (key, val, epilog) => {
650
+ const type = typeof val === "number" ? "number" : "string";
651
+ const body = typeof val === "number" ? String(val) : JSON.stringify(val);
696
652
  const k = root.create("string").set({ body: JSON.stringify(key), value: key, epilog: ": " });
697
- const v = root.create("number").set({ body: String(val), value: val });
653
+ const v = root.create(type).set({ body, value: val });
698
654
  const m = root.create("member");
699
655
  if (epilog !== undefined)
700
656
  m.set({ epilog });
701
657
  return m.add(k, v);
702
658
  };
703
659
  const obj = root.create("object").set({ prolog: "{\n ", epilog: "\n }\n" });
704
- obj.add(strMember("type", "command", ",\n "), strMember("command", command, ",\n "), numMember("padding", 0));
660
+ obj.add(member("type", "command", ",\n "), member("command", command, ",\n "), member("padding", 0));
705
661
  const key = root.create("string").set({
706
662
  body: JSON.stringify("statusLine"),
707
663
  value: "statusLine",
package/dst/ase-skills.js CHANGED
@@ -54,7 +54,7 @@ export class Skills {
54
54
  }
55
55
  /* fetch GitHub stars given a repository URL (or empty string) */
56
56
  static async fetchStars(repository) {
57
- const m = /^.+?\/\/github\.com\/([^/]+\/[^/#?]+?)(?:\.git)?(?:[/#?].*)?$/.exec(repository);
57
+ const m = /^(?:.*?:\/\/)?(?:[^@/]*@)?github\.com[:/]([^/]+\/[^/#?]+?)(?:\.git)?(?:[/#?].*)?$/.exec(repository);
58
58
  if (m === null)
59
59
  return "N.A.";
60
60
  try {