@ariobarin/glossa 0.1.0-beta.10 → 0.1.0-beta.12
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 +17 -10
- package/dist/app.js +2662 -1549
- package/package.json +1 -1
package/dist/app.js
CHANGED
|
@@ -5,6 +5,34 @@ var __export = (target, all) => {
|
|
|
5
5
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
+
// src/auth-config.ts
|
|
9
|
+
var DEFAULT_AUTH_CONFIG = {
|
|
10
|
+
issuer: "https://dev-fl2h5xhp6umeh74m.us.auth0.com/",
|
|
11
|
+
clientId: "9mwnK9nTAd8q1kxnKIZxC1wodxzfWHg5",
|
|
12
|
+
audience: "https://mcp.glossa.sh/",
|
|
13
|
+
scope: "openid profile offline_access glossa:device"
|
|
14
|
+
};
|
|
15
|
+
function configuredValue(environment, name, fallback) {
|
|
16
|
+
const value = environment[name]?.trim();
|
|
17
|
+
return value || fallback;
|
|
18
|
+
}
|
|
19
|
+
function loadAuthConfig(environment = process.env) {
|
|
20
|
+
return {
|
|
21
|
+
issuer: configuredValue(environment, "GLOSSA_AUTH0_ISSUER", DEFAULT_AUTH_CONFIG.issuer),
|
|
22
|
+
clientId: configuredValue(
|
|
23
|
+
environment,
|
|
24
|
+
"GLOSSA_AUTH0_CLI_CLIENT_ID",
|
|
25
|
+
DEFAULT_AUTH_CONFIG.clientId
|
|
26
|
+
),
|
|
27
|
+
audience: configuredValue(
|
|
28
|
+
environment,
|
|
29
|
+
"GLOSSA_AUTH0_AUDIENCE",
|
|
30
|
+
DEFAULT_AUTH_CONFIG.audience
|
|
31
|
+
),
|
|
32
|
+
scope: DEFAULT_AUTH_CONFIG.scope
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
8
36
|
// src/config-store.ts
|
|
9
37
|
import path2 from "node:path";
|
|
10
38
|
|
|
@@ -239,6 +267,21 @@ var SessionExpiredError = class extends Error {
|
|
|
239
267
|
this.name = "SessionExpiredError";
|
|
240
268
|
}
|
|
241
269
|
};
|
|
270
|
+
function accessTokenSubject(credentials) {
|
|
271
|
+
try {
|
|
272
|
+
const parts = credentials.accessToken.split(".");
|
|
273
|
+
if (parts.length !== 3) throw new Error();
|
|
274
|
+
const payload = JSON.parse(
|
|
275
|
+
Buffer.from(parts[1], "base64url").toString("utf8")
|
|
276
|
+
);
|
|
277
|
+
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
|
|
278
|
+
throw new Error();
|
|
279
|
+
}
|
|
280
|
+
return payload.sub;
|
|
281
|
+
} catch {
|
|
282
|
+
throw new Error("Glossa could not identify the signed-in account.");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
242
285
|
function sessionExpiredError() {
|
|
243
286
|
return new SessionExpiredError();
|
|
244
287
|
}
|
|
@@ -331,34 +374,6 @@ async function loadUserProfile(credentials, dependencies = {}) {
|
|
|
331
374
|
return { credentials: current, profile: parseProfile(await response.json()) };
|
|
332
375
|
}
|
|
333
376
|
|
|
334
|
-
// src/auth-config.ts
|
|
335
|
-
var DEFAULT_AUTH_CONFIG = {
|
|
336
|
-
issuer: "https://dev-fl2h5xhp6umeh74m.us.auth0.com/",
|
|
337
|
-
clientId: "9mwnK9nTAd8q1kxnKIZxC1wodxzfWHg5",
|
|
338
|
-
audience: "https://mcp.glossa.sh/",
|
|
339
|
-
scope: "openid profile offline_access glossa:device"
|
|
340
|
-
};
|
|
341
|
-
function configuredValue(environment, name, fallback) {
|
|
342
|
-
const value = environment[name]?.trim();
|
|
343
|
-
return value || fallback;
|
|
344
|
-
}
|
|
345
|
-
function loadAuthConfig(environment = process.env) {
|
|
346
|
-
return {
|
|
347
|
-
issuer: configuredValue(environment, "GLOSSA_AUTH0_ISSUER", DEFAULT_AUTH_CONFIG.issuer),
|
|
348
|
-
clientId: configuredValue(
|
|
349
|
-
environment,
|
|
350
|
-
"GLOSSA_AUTH0_CLI_CLIENT_ID",
|
|
351
|
-
DEFAULT_AUTH_CONFIG.clientId
|
|
352
|
-
),
|
|
353
|
-
audience: configuredValue(
|
|
354
|
-
environment,
|
|
355
|
-
"GLOSSA_AUTH0_AUDIENCE",
|
|
356
|
-
DEFAULT_AUTH_CONFIG.audience
|
|
357
|
-
),
|
|
358
|
-
scope: DEFAULT_AUTH_CONFIG.scope
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
|
|
362
377
|
// src/device-flow.ts
|
|
363
378
|
import { setTimeout as delay } from "node:timers/promises";
|
|
364
379
|
|
|
@@ -500,30 +515,34 @@ function credentialsMatchLoginOptions(credentials, options) {
|
|
|
500
515
|
Boolean(credentials.refreshToken)
|
|
501
516
|
);
|
|
502
517
|
}
|
|
503
|
-
async function
|
|
518
|
+
async function signedInSession(options, dependencies = {}) {
|
|
504
519
|
const load = dependencies.loadCredentials ?? loadCredentials;
|
|
505
520
|
const validate = dependencies.validCredentials ?? validCredentials;
|
|
506
521
|
const login = dependencies.loginWithDeviceFlow ?? loginWithDeviceFlow;
|
|
507
522
|
const loaded = await load();
|
|
508
523
|
if (loaded && credentialsMatchLoginOptions(loaded.credentials, options)) {
|
|
509
524
|
try {
|
|
510
|
-
await validate(
|
|
525
|
+
const credentials = await validate(
|
|
511
526
|
loaded.credentials,
|
|
512
527
|
options.signal ? { signal: options.signal } : {}
|
|
513
528
|
);
|
|
514
|
-
return false;
|
|
529
|
+
return { credentials, loginPerformed: false };
|
|
515
530
|
} catch (error46) {
|
|
516
531
|
if (!(error46 instanceof SessionExpiredError)) throw error46;
|
|
517
532
|
}
|
|
518
533
|
}
|
|
519
534
|
await login(options);
|
|
520
|
-
|
|
535
|
+
const completed = await load();
|
|
536
|
+
if (!completed) throw new Error("Glossa could not load the completed login.");
|
|
537
|
+
return {
|
|
538
|
+
credentials: await validate(
|
|
539
|
+
completed.credentials,
|
|
540
|
+
options.signal ? { signal: options.signal } : {}
|
|
541
|
+
),
|
|
542
|
+
loginPerformed: true
|
|
543
|
+
};
|
|
521
544
|
}
|
|
522
545
|
|
|
523
|
-
// src/cli-options.ts
|
|
524
|
-
import { existsSync } from "node:fs";
|
|
525
|
-
import path3 from "node:path";
|
|
526
|
-
|
|
527
546
|
// ../../node_modules/zod/v4/classic/external.js
|
|
528
547
|
var external_exports = {};
|
|
529
548
|
__export(external_exports, {
|
|
@@ -13070,12 +13089,20 @@ config(en_default());
|
|
|
13070
13089
|
var MAX_TEXT_BYTES = 1024 * 1024;
|
|
13071
13090
|
var MAX_EDIT_DIFF_BYTES = 128 * 1024;
|
|
13072
13091
|
var MAX_EDIT_OPERATIONS = 100;
|
|
13092
|
+
var MAX_COMMAND_OUTPUT_BYTES = 12 * 1024;
|
|
13073
13093
|
var DEFAULT_COMMAND_TIMEOUT_MS = 15 * 60 * 1e3;
|
|
13074
13094
|
var MAX_COMMAND_TIMEOUT_MS = 60 * 60 * 1e3;
|
|
13075
13095
|
var DEFAULT_COMMAND_FAST_WAIT_MS = 750;
|
|
13076
13096
|
var MAX_COMMAND_FAST_WAIT_MS = 5e3;
|
|
13077
13097
|
var MAX_COMMAND_STATUS_WAIT_MS = 15e3;
|
|
13098
|
+
var MAX_LIST_FILES_RESULTS = 200;
|
|
13099
|
+
var MAX_SEARCH_TEXT_RESULTS = 100;
|
|
13100
|
+
var MAX_SEARCH_TEXT_SNIPPET_CHARS = 400;
|
|
13101
|
+
var MAX_READ_FILE_RANGE_LINES = 500;
|
|
13102
|
+
var MAX_READ_FILE_RANGE_BYTES = 64 * 1024;
|
|
13103
|
+
var MAX_STRUCTURED_READ_TIMEOUT_MS = 8e3;
|
|
13078
13104
|
var deviceNameSchema = external_exports.string().trim().min(1).max(80).regex(/^[^\u0000-\u001f\u007f]+$/, "Device name contains control characters");
|
|
13105
|
+
var workspaceLabelSchema = external_exports.string().trim().min(1).max(80).regex(/^[^\u0000-\u001f\u007f]+$/, "Workspace label contains control characters");
|
|
13079
13106
|
var relativePathSchema = external_exports.string().max(4096).describe("Path relative to the exposed workspace root. Absolute paths and parent traversal are rejected.");
|
|
13080
13107
|
var boundedTextSchema = external_exports.string().refine((value) => Buffer.byteLength(value, "utf8") <= MAX_TEXT_BYTES);
|
|
13081
13108
|
var readFileRequestSchema = external_exports.object({
|
|
@@ -13085,6 +13112,41 @@ var readFileJobSchema = readFileRequestSchema.extend({
|
|
|
13085
13112
|
type: external_exports.literal("read_file"),
|
|
13086
13113
|
requestId: external_exports.string().uuid()
|
|
13087
13114
|
});
|
|
13115
|
+
var structuredReadTimeoutSchema = external_exports.number().int().min(1).max(MAX_STRUCTURED_READ_TIMEOUT_MS);
|
|
13116
|
+
var listFilesCursorSchema = external_exports.string().max(4096).describe("Opaque cursor returned by an earlier list_files result.");
|
|
13117
|
+
var listFilesRequestSchema = external_exports.object({
|
|
13118
|
+
path: relativePathSchema.optional().describe("Directory relative to the exposed root. Defaults to the root."),
|
|
13119
|
+
recursive: external_exports.boolean().optional().describe("Whether to include descendants. Defaults to false."),
|
|
13120
|
+
cursor: listFilesCursorSchema.optional(),
|
|
13121
|
+
limit: external_exports.number().int().min(1).max(MAX_LIST_FILES_RESULTS).optional().describe("Maximum entries to return, from 1 through 200. Defaults to 100.")
|
|
13122
|
+
}).strict();
|
|
13123
|
+
var listFilesJobSchema = listFilesRequestSchema.extend({
|
|
13124
|
+
type: external_exports.literal("list_files"),
|
|
13125
|
+
requestId: external_exports.string().uuid(),
|
|
13126
|
+
timeoutMs: structuredReadTimeoutSchema
|
|
13127
|
+
});
|
|
13128
|
+
var searchTextRequestSchema = external_exports.object({
|
|
13129
|
+
query: external_exports.string().min(1).max(256).refine((value) => !/[\r\n\u0000]/.test(value), "Search text must fit on one line").describe("Literal single-line UTF-8 text to search for."),
|
|
13130
|
+
path: relativePathSchema.optional().describe("File or directory relative to the exposed root. Defaults to the root."),
|
|
13131
|
+
caseSensitive: external_exports.boolean().optional().describe("Whether matching is case-sensitive. Defaults to false."),
|
|
13132
|
+
maxResults: external_exports.number().int().min(1).max(MAX_SEARCH_TEXT_RESULTS).optional().describe("Maximum matching lines to return, from 1 through 100. Defaults to 50."),
|
|
13133
|
+
extensions: external_exports.array(external_exports.string().regex(/^\.[A-Za-z0-9][A-Za-z0-9._-]{0,19}$/).describe("Filename suffix including the leading dot, such as .ts or .d.ts.")).min(1).max(20).optional().describe("Optional filename extensions to search.")
|
|
13134
|
+
}).strict();
|
|
13135
|
+
var searchTextJobSchema = searchTextRequestSchema.extend({
|
|
13136
|
+
type: external_exports.literal("search_text"),
|
|
13137
|
+
requestId: external_exports.string().uuid(),
|
|
13138
|
+
timeoutMs: structuredReadTimeoutSchema
|
|
13139
|
+
});
|
|
13140
|
+
var readFileRangeRequestSchema = external_exports.object({
|
|
13141
|
+
path: relativePathSchema,
|
|
13142
|
+
startLine: external_exports.number().int().min(1).optional().describe("First one-based line to return. Defaults to 1."),
|
|
13143
|
+
lineCount: external_exports.number().int().min(1).max(MAX_READ_FILE_RANGE_LINES).optional().describe("Maximum complete lines to return, from 1 through 500. Defaults to 200.")
|
|
13144
|
+
}).strict();
|
|
13145
|
+
var readFileRangeJobSchema = readFileRangeRequestSchema.extend({
|
|
13146
|
+
type: external_exports.literal("read_file_range"),
|
|
13147
|
+
requestId: external_exports.string().uuid(),
|
|
13148
|
+
timeoutMs: structuredReadTimeoutSchema
|
|
13149
|
+
});
|
|
13088
13150
|
var writeFileRequestSchema = external_exports.object({
|
|
13089
13151
|
path: relativePathSchema,
|
|
13090
13152
|
content: boundedTextSchema.describe("Complete UTF-8 text content that will replace the file."),
|
|
@@ -13138,7 +13200,8 @@ var runCommandJobSchema = runCommandRequestSchema.safeExtend({
|
|
|
13138
13200
|
});
|
|
13139
13201
|
var getCommandRequestSchema = external_exports.object({
|
|
13140
13202
|
commandId: external_exports.string().uuid().describe("Command identifier returned by run_command."),
|
|
13141
|
-
waitMs: external_exports.number().int().min(0).max(MAX_COMMAND_STATUS_WAIT_MS).optional().describe("Optional long-poll duration in milliseconds, from 0 through 15000.")
|
|
13203
|
+
waitMs: external_exports.number().int().min(0).max(MAX_COMMAND_STATUS_WAIT_MS).optional().describe("Optional long-poll duration in milliseconds, from 0 through 15000."),
|
|
13204
|
+
afterSequence: external_exports.number().int().min(0).optional().describe("Sequence returned by an earlier command result. When current, wait for output or status to change.")
|
|
13142
13205
|
}).strict();
|
|
13143
13206
|
var getCommandJobSchema = getCommandRequestSchema.extend({
|
|
13144
13207
|
type: external_exports.literal("get_command"),
|
|
@@ -13153,6 +13216,9 @@ var cancelCommandJobSchema = cancelCommandRequestSchema.extend({
|
|
|
13153
13216
|
});
|
|
13154
13217
|
var workerJobSchema = external_exports.discriminatedUnion("type", [
|
|
13155
13218
|
readFileJobSchema,
|
|
13219
|
+
listFilesJobSchema,
|
|
13220
|
+
searchTextJobSchema,
|
|
13221
|
+
readFileRangeJobSchema,
|
|
13156
13222
|
writeFileJobSchema,
|
|
13157
13223
|
editFileJobSchema,
|
|
13158
13224
|
runCommandJobSchema,
|
|
@@ -13170,468 +13236,144 @@ var workerResultSchema = external_exports.object({
|
|
|
13170
13236
|
}).optional()
|
|
13171
13237
|
});
|
|
13172
13238
|
|
|
13173
|
-
// src/completions.ts
|
|
13174
|
-
var SUPPORTED_SHELLS = ["powershell", "bash", "zsh", "fish"];
|
|
13175
|
-
var commands = [
|
|
13176
|
-
"ui",
|
|
13177
|
-
"start",
|
|
13178
|
-
"status",
|
|
13179
|
-
"doctor",
|
|
13180
|
-
"devices",
|
|
13181
|
-
"completions",
|
|
13182
|
-
"update",
|
|
13183
|
-
"upgrade",
|
|
13184
|
-
"login",
|
|
13185
|
-
"logout"
|
|
13186
|
-
];
|
|
13187
|
-
function powershellScript() {
|
|
13188
|
-
const list = commands.map((command) => `'${command}'`).join(", ");
|
|
13189
|
-
return `# PowerShell completion for Glossa. Source it from your PowerShell profile.
|
|
13190
|
-
Register-ArgumentCompleter -Native -CommandName glossa -ScriptBlock {
|
|
13191
|
-
param($wordToComplete, $commandAst, $cursorPosition)
|
|
13192
|
-
$commands = @(${list})
|
|
13193
|
-
$elements = $commandAst.CommandElements
|
|
13194
|
-
$last = $elements[$elements.Count - 1]
|
|
13195
|
-
# Position of the argument being completed (1 = first argument after glossa).
|
|
13196
|
-
# A trailing space starts a new argument; otherwise we complete the last token.
|
|
13197
|
-
if ($cursorPosition -gt $last.Extent.EndOffset) {
|
|
13198
|
-
$position = $elements.Count
|
|
13199
|
-
} else {
|
|
13200
|
-
$position = $elements.Count - 1
|
|
13201
|
-
}
|
|
13202
|
-
if ($position -eq 1) {
|
|
13203
|
-
@($commands + '--help' + '--version') |
|
|
13204
|
-
Where-Object { $_ -like "$wordToComplete*" } |
|
|
13205
|
-
ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
|
|
13206
|
-
return
|
|
13207
|
-
}
|
|
13208
|
-
$command = $elements[1].Value
|
|
13209
|
-
if ($wordToComplete -like '-*') {
|
|
13210
|
-
switch ($command) {
|
|
13211
|
-
'ui' {
|
|
13212
|
-
@('--allow-broad-root', '--device-name') | Where-Object { $_ -like "$wordToComplete*" } |
|
|
13213
|
-
ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) }
|
|
13214
|
-
}
|
|
13215
|
-
'start' {
|
|
13216
|
-
@('--allow-broad-root', '--device-name') | Where-Object { $_ -like "$wordToComplete*" } |
|
|
13217
|
-
ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) }
|
|
13218
|
-
}
|
|
13219
|
-
'status' {
|
|
13220
|
-
@('--json') | Where-Object { $_ -like "$wordToComplete*" } |
|
|
13221
|
-
ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) }
|
|
13222
|
-
}
|
|
13223
|
-
'doctor' {
|
|
13224
|
-
@('--json') | Where-Object { $_ -like "$wordToComplete*" } |
|
|
13225
|
-
ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) }
|
|
13226
|
-
}
|
|
13227
|
-
'devices' {
|
|
13228
|
-
if ($position -eq 3 -and $elements[2].Value -eq 'list') {
|
|
13229
|
-
@('--json') | Where-Object { $_ -like "$wordToComplete*" } |
|
|
13230
|
-
ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) }
|
|
13231
|
-
}
|
|
13232
|
-
}
|
|
13233
|
-
'logout' {
|
|
13234
|
-
@('--browser') | Where-Object { $_ -like "$wordToComplete*" } |
|
|
13235
|
-
ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) }
|
|
13236
|
-
}
|
|
13237
|
-
}
|
|
13238
|
-
return
|
|
13239
|
-
}
|
|
13240
|
-
if ($position -eq 2) {
|
|
13241
|
-
switch ($command) {
|
|
13242
|
-
'devices' {
|
|
13243
|
-
@('list', 'rename', 'revoke') | Where-Object { $_ -like "$wordToComplete*" } |
|
|
13244
|
-
ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
|
|
13245
|
-
}
|
|
13246
|
-
'completions' {
|
|
13247
|
-
@('powershell', 'bash', 'zsh', 'fish') | Where-Object { $_ -like "$wordToComplete*" } |
|
|
13248
|
-
ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
|
|
13249
|
-
}
|
|
13250
|
-
}
|
|
13251
|
-
}
|
|
13252
|
-
}
|
|
13253
|
-
`;
|
|
13254
|
-
}
|
|
13255
|
-
function bashScript() {
|
|
13256
|
-
return `# Bash completion for Glossa. Source it or install under /etc/bash_completion.d.
|
|
13257
|
-
_glossa() {
|
|
13258
|
-
local cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
13259
|
-
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
13260
|
-
COMPREPLY=( $(compgen -W "${commands.join(" ")} --help --version" -- "$cur") )
|
|
13261
|
-
return
|
|
13262
|
-
fi
|
|
13263
|
-
local cmd="\${COMP_WORDS[1]}"
|
|
13264
|
-
if [[ "$cur" == -* ]]; then
|
|
13265
|
-
case "$cmd" in
|
|
13266
|
-
ui|start) COMPREPLY=( $(compgen -W "--allow-broad-root --device-name" -- "$cur") ) ;;
|
|
13267
|
-
status|doctor) COMPREPLY=( $(compgen -W "--json" -- "$cur") ) ;;
|
|
13268
|
-
devices)
|
|
13269
|
-
if [ "$COMP_CWORD" -eq 3 ] && [ "\${COMP_WORDS[2]}" = "list" ]; then
|
|
13270
|
-
COMPREPLY=( $(compgen -W "--json" -- "$cur") )
|
|
13271
|
-
fi ;;
|
|
13272
|
-
logout) COMPREPLY=( $(compgen -W "--browser" -- "$cur") ) ;;
|
|
13273
|
-
esac
|
|
13274
|
-
return
|
|
13275
|
-
fi
|
|
13276
|
-
case "$cmd" in
|
|
13277
|
-
devices)
|
|
13278
|
-
if [ "$COMP_CWORD" -eq 2 ]; then
|
|
13279
|
-
COMPREPLY=( $(compgen -W "list rename revoke" -- "$cur") )
|
|
13280
|
-
fi ;;
|
|
13281
|
-
completions)
|
|
13282
|
-
if [ "$COMP_CWORD" -eq 2 ]; then
|
|
13283
|
-
COMPREPLY=( $(compgen -W "powershell bash zsh fish" -- "$cur") )
|
|
13284
|
-
fi ;;
|
|
13285
|
-
ui|start) ;; # workspace directory: fall through to filename completion
|
|
13286
|
-
esac
|
|
13287
|
-
}
|
|
13288
|
-
# -o default lets readline fall back to filename completion for workspace paths
|
|
13289
|
-
# when the completion function produces no matches.
|
|
13290
|
-
complete -o default -F _glossa glossa
|
|
13291
|
-
`;
|
|
13292
|
-
}
|
|
13293
|
-
function zshScript() {
|
|
13294
|
-
return `# Zsh completion for Glossa. Source this after compinit from your profile.
|
|
13295
|
-
_glossa() {
|
|
13296
|
-
local context state state_descr line
|
|
13297
|
-
typeset -A opt_args
|
|
13298
|
-
local -a glossa_commands
|
|
13299
|
-
glossa_commands=(
|
|
13300
|
-
'ui:open the interactive session HUD'
|
|
13301
|
-
'start:expose a workspace'
|
|
13302
|
-
'status:show account, relay, and active workers'
|
|
13303
|
-
'doctor:check local and relay readiness'
|
|
13304
|
-
'devices:manage enrolled computers'
|
|
13305
|
-
'completions:emit a shell completion script'
|
|
13306
|
-
'update:update the current Glossa installation'
|
|
13307
|
-
'upgrade:alias for update'
|
|
13308
|
-
'login:ensure a Glossa session'
|
|
13309
|
-
'logout:remove local credentials'
|
|
13310
|
-
)
|
|
13311
|
-
_arguments -C '1:command or workspace:->commands' '*::argument:->args'
|
|
13312
|
-
case "$state" in
|
|
13313
|
-
commands)
|
|
13314
|
-
_describe 'glossa command' glossa_commands
|
|
13315
|
-
_files
|
|
13316
|
-
;;
|
|
13317
|
-
args)
|
|
13318
|
-
case $words[2] in
|
|
13319
|
-
devices)
|
|
13320
|
-
if (( CURRENT == 3 )); then
|
|
13321
|
-
_values 'device action' list rename revoke
|
|
13322
|
-
elif [[ $words[3] == list ]]; then
|
|
13323
|
-
_arguments '--json[print machine-readable JSON]'
|
|
13324
|
-
fi
|
|
13325
|
-
;;
|
|
13326
|
-
completions) _arguments '2:shell:(powershell bash zsh fish)' ;;
|
|
13327
|
-
ui|start)
|
|
13328
|
-
_arguments '--allow-broad-root[allow home or drive roots]' '--device-name[name this computer on first enrollment]:device name:' '2:workspace:_directories'
|
|
13329
|
-
;;
|
|
13330
|
-
status|doctor) _arguments '--json[print machine-readable JSON]' ;;
|
|
13331
|
-
logout) _arguments '--browser[also sign out of the browser session]' ;;
|
|
13332
|
-
esac
|
|
13333
|
-
;;
|
|
13334
|
-
esac
|
|
13335
|
-
}
|
|
13336
|
-
compdef _glossa glossa
|
|
13337
|
-
`;
|
|
13338
|
-
}
|
|
13339
|
-
function fishScript() {
|
|
13340
|
-
const lines = [
|
|
13341
|
-
"# Fish completion for Glossa. Source it or drop into ~/.config/fish/completions.",
|
|
13342
|
-
// No global -f: the first argument may be a workspace directory, so fish
|
|
13343
|
-
// should still offer files there.
|
|
13344
|
-
...commands.map(
|
|
13345
|
-
(command) => `complete -c glossa -n '__fish_use_subcommand' -a '${command}'`
|
|
13346
|
-
),
|
|
13347
|
-
"complete -c glossa -f -n '__fish_seen_subcommand_from devices; and test (count (commandline -opc)) -eq 2' -a 'list rename revoke'",
|
|
13348
|
-
"complete -c glossa -f -n '__fish_seen_subcommand_from completions; and test (count (commandline -opc)) -eq 2' -a 'powershell bash zsh fish'",
|
|
13349
|
-
"complete -c glossa -n '__fish_seen_subcommand_from ui start' -l allow-broad-root",
|
|
13350
|
-
"complete -c glossa -n '__fish_seen_subcommand_from ui start' -l device-name -r",
|
|
13351
|
-
"complete -c glossa -n '__fish_seen_subcommand_from status doctor' -l json",
|
|
13352
|
-
"complete -c glossa -n '__fish_seen_subcommand_from devices; and contains -- list (commandline -opc)' -l json",
|
|
13353
|
-
"complete -c glossa -n '__fish_seen_subcommand_from logout' -l browser",
|
|
13354
|
-
""
|
|
13355
|
-
];
|
|
13356
|
-
return lines.join("\n");
|
|
13357
|
-
}
|
|
13358
|
-
function completionScript(shell) {
|
|
13359
|
-
switch (shell) {
|
|
13360
|
-
case "powershell":
|
|
13361
|
-
return powershellScript();
|
|
13362
|
-
case "bash":
|
|
13363
|
-
return bashScript();
|
|
13364
|
-
case "zsh":
|
|
13365
|
-
return zshScript();
|
|
13366
|
-
case "fish":
|
|
13367
|
-
return fishScript();
|
|
13368
|
-
}
|
|
13369
|
-
}
|
|
13370
|
-
|
|
13371
13239
|
// src/cli-options.ts
|
|
13372
13240
|
var UsageError = class extends Error {
|
|
13373
13241
|
};
|
|
13374
|
-
var
|
|
13375
|
-
"ui",
|
|
13376
|
-
"start",
|
|
13377
|
-
"status",
|
|
13378
|
-
"doctor",
|
|
13379
|
-
"devices",
|
|
13242
|
+
var retiredCommands = /* @__PURE__ */ new Set([
|
|
13380
13243
|
"completions",
|
|
13381
|
-
"
|
|
13244
|
+
"doctor",
|
|
13382
13245
|
"login",
|
|
13383
|
-
"
|
|
13246
|
+
"start",
|
|
13247
|
+
"update"
|
|
13384
13248
|
]);
|
|
13385
|
-
function
|
|
13386
|
-
const parsed = deviceNameSchema.safeParse(value);
|
|
13387
|
-
if (!parsed.success) {
|
|
13388
|
-
throw new UsageError(
|
|
13389
|
-
"Device names must be 1 to 80 characters with no control characters."
|
|
13390
|
-
);
|
|
13391
|
-
}
|
|
13392
|
-
return parsed.data;
|
|
13393
|
-
}
|
|
13394
|
-
function parseWorkspaceCommand(command, args) {
|
|
13395
|
-
if (args.includes("--help") || args.includes("-h")) {
|
|
13396
|
-
return { command: "help", topic: command };
|
|
13397
|
-
}
|
|
13249
|
+
function parseWorkspace(args) {
|
|
13398
13250
|
let selectedPath;
|
|
13399
|
-
let
|
|
13400
|
-
let deviceName;
|
|
13251
|
+
let label;
|
|
13401
13252
|
let optionsEnded = false;
|
|
13402
13253
|
for (let index = 0; index < args.length; index += 1) {
|
|
13403
13254
|
const argument = args[index];
|
|
13404
13255
|
if (!optionsEnded && argument === "--") {
|
|
13405
13256
|
optionsEnded = true;
|
|
13406
|
-
} else if (!optionsEnded && argument === "--
|
|
13407
|
-
|
|
13408
|
-
|
|
13257
|
+
} else if (!optionsEnded && argument === "--label") {
|
|
13258
|
+
if (label !== void 0) {
|
|
13259
|
+
throw new UsageError("Glossa accepts at most one workspace label.");
|
|
13260
|
+
}
|
|
13409
13261
|
const value = args[index + 1];
|
|
13410
|
-
if (value === void 0 || value
|
|
13411
|
-
throw new UsageError("--
|
|
13262
|
+
if (value === void 0 || value === "--") {
|
|
13263
|
+
throw new UsageError("Use --label <name>.");
|
|
13264
|
+
}
|
|
13265
|
+
const parsed = workspaceLabelSchema.safeParse(value);
|
|
13266
|
+
if (!parsed.success) {
|
|
13267
|
+
throw new UsageError("Workspace labels must be 1-80 printable characters.");
|
|
13412
13268
|
}
|
|
13413
|
-
|
|
13269
|
+
label = parsed.data;
|
|
13414
13270
|
index += 1;
|
|
13415
|
-
} else if (!optionsEnded && argument.startsWith("--device-name=")) {
|
|
13416
|
-
deviceName = parseDeviceName(argument.slice("--device-name=".length));
|
|
13417
13271
|
} else if (!optionsEnded && argument.startsWith("-")) {
|
|
13418
|
-
throw new UsageError(`Unknown
|
|
13272
|
+
throw new UsageError(`Unknown option: ${argument}`);
|
|
13419
13273
|
} else if (selectedPath) {
|
|
13420
|
-
throw new UsageError(
|
|
13274
|
+
throw new UsageError("Glossa accepts at most one directory.");
|
|
13275
|
+
} else if (!optionsEnded && retiredCommands.has(argument)) {
|
|
13276
|
+
throw new UsageError(`The ${argument} command is no longer available.`);
|
|
13421
13277
|
} else {
|
|
13422
13278
|
selectedPath = argument;
|
|
13423
13279
|
}
|
|
13424
13280
|
}
|
|
13425
13281
|
return {
|
|
13426
|
-
command,
|
|
13282
|
+
command: "workspace",
|
|
13427
13283
|
...selectedPath ? { path: selectedPath } : {},
|
|
13428
|
-
|
|
13429
|
-
...deviceName ? { deviceName } : {}
|
|
13284
|
+
...label ? { label } : {}
|
|
13430
13285
|
};
|
|
13431
13286
|
}
|
|
13432
|
-
function singleJsonOption(command, args) {
|
|
13433
|
-
if (args.length === 0) return false;
|
|
13434
|
-
if (args.length === 1 && args[0] === "--json") return true;
|
|
13435
|
-
throw new UsageError(`${command} accepts only --json.`);
|
|
13436
|
-
}
|
|
13437
13287
|
function parseDevices(args) {
|
|
13438
|
-
|
|
13439
|
-
|
|
13440
|
-
return { command: "help", topic: "devices" };
|
|
13441
|
-
}
|
|
13442
|
-
if (action === "list") {
|
|
13443
|
-
return { command: "devices", action, json: singleJsonOption("Devices list", options) };
|
|
13444
|
-
}
|
|
13445
|
-
if (action === "rename" && options.length === 2) {
|
|
13446
|
-
return { command: "devices", action, deviceId: options[0], name: options[1] };
|
|
13447
|
-
}
|
|
13448
|
-
if (action === "revoke" && options.length === 1) {
|
|
13449
|
-
return { command: "devices", action, deviceId: options[0] };
|
|
13288
|
+
if (args[0] === "revoke" && args.length === 2) {
|
|
13289
|
+
return { command: "devices", action: "revoke", deviceId: args[1] };
|
|
13450
13290
|
}
|
|
13451
|
-
throw new UsageError("Use: glossa devices
|
|
13291
|
+
throw new UsageError("Use: glossa devices revoke <id>.");
|
|
13452
13292
|
}
|
|
13453
|
-
function
|
|
13454
|
-
|
|
13455
|
-
}
|
|
13456
|
-
var KNOWN_COMMANDS = [
|
|
13457
|
-
"ui",
|
|
13458
|
-
"start",
|
|
13459
|
-
"status",
|
|
13460
|
-
"doctor",
|
|
13461
|
-
"devices",
|
|
13462
|
-
"completions",
|
|
13463
|
-
"update",
|
|
13464
|
-
"upgrade",
|
|
13465
|
-
"login",
|
|
13466
|
-
"logout"
|
|
13467
|
-
];
|
|
13468
|
-
function editDistance(a, b) {
|
|
13469
|
-
const m = a.length;
|
|
13470
|
-
const n = b.length;
|
|
13471
|
-
if (m === 0) return n;
|
|
13472
|
-
if (n === 0) return m;
|
|
13473
|
-
let previous = Array.from({ length: n + 1 }, (_, index) => index);
|
|
13474
|
-
for (let i = 1; i <= m; i += 1) {
|
|
13475
|
-
const current = [i];
|
|
13476
|
-
for (let j = 1; j <= n; j += 1) {
|
|
13477
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
13478
|
-
current[j] = Math.min(
|
|
13479
|
-
previous[j] + 1,
|
|
13480
|
-
current[j - 1] + 1,
|
|
13481
|
-
previous[j - 1] + cost
|
|
13482
|
-
);
|
|
13483
|
-
}
|
|
13484
|
-
previous = current;
|
|
13485
|
-
}
|
|
13486
|
-
return previous[n];
|
|
13487
|
-
}
|
|
13488
|
-
function suggestCommand(input) {
|
|
13489
|
-
const lower = input.toLowerCase();
|
|
13490
|
-
if (lower.length >= 3) {
|
|
13491
|
-
const prefixMatches = KNOWN_COMMANDS.filter((command) => command.startsWith(lower));
|
|
13492
|
-
if (prefixMatches.length === 1) return prefixMatches[0];
|
|
13493
|
-
}
|
|
13494
|
-
let best;
|
|
13495
|
-
let bestDistance = Infinity;
|
|
13496
|
-
for (const command of KNOWN_COMMANDS) {
|
|
13497
|
-
const distance = editDistance(lower, command);
|
|
13498
|
-
if (distance < bestDistance) {
|
|
13499
|
-
bestDistance = distance;
|
|
13500
|
-
best = command;
|
|
13501
|
-
}
|
|
13502
|
-
}
|
|
13503
|
-
if (best && bestDistance <= 3 && bestDistance <= Math.ceil(lower.length / 2)) {
|
|
13504
|
-
return best;
|
|
13505
|
-
}
|
|
13506
|
-
return void 0;
|
|
13293
|
+
function noOptions(command, args) {
|
|
13294
|
+
if (args.length > 0) throw new UsageError(`${command} accepts no options.`);
|
|
13507
13295
|
}
|
|
13508
13296
|
function parseInvocation(args) {
|
|
13509
13297
|
const [command, ...options] = args;
|
|
13510
|
-
if (!command) return
|
|
13298
|
+
if (!command) return parseWorkspace([]);
|
|
13511
13299
|
if (command === "--help" || command === "-h") {
|
|
13512
|
-
|
|
13300
|
+
noOptions("Help", options);
|
|
13513
13301
|
return { command: "help" };
|
|
13514
13302
|
}
|
|
13515
|
-
if (command === "help") {
|
|
13516
|
-
if (options.length > 1) throw new UsageError("Help accepts one command name.");
|
|
13517
|
-
const topic = options[0];
|
|
13518
|
-
if (!topic) return { command: "help" };
|
|
13519
|
-
if (!helpTopics.has(topic)) {
|
|
13520
|
-
throw new UsageError(`Unknown help topic: ${topic}`);
|
|
13521
|
-
}
|
|
13522
|
-
return { command: "help", topic };
|
|
13523
|
-
}
|
|
13524
13303
|
if (command === "--version" || command === "-v") {
|
|
13525
|
-
|
|
13304
|
+
noOptions("Version", options);
|
|
13526
13305
|
return { command: "version" };
|
|
13527
13306
|
}
|
|
13528
|
-
if (command === "
|
|
13529
|
-
if (command === "start") return parseWorkspaceCommand("start", options);
|
|
13307
|
+
if (command === "--") return parseWorkspace(args);
|
|
13530
13308
|
if (command === "status") {
|
|
13531
|
-
|
|
13532
|
-
|
|
13533
|
-
}
|
|
13534
|
-
return { command: "status", json: singleJsonOption("Status", options) };
|
|
13535
|
-
}
|
|
13536
|
-
if (command === "doctor") {
|
|
13537
|
-
if (options.includes("--help") || options.includes("-h")) {
|
|
13538
|
-
return { command: "help", topic: "doctor" };
|
|
13539
|
-
}
|
|
13540
|
-
return { command: "doctor", json: singleJsonOption("Doctor", options) };
|
|
13309
|
+
noOptions("Status", options);
|
|
13310
|
+
return { command };
|
|
13541
13311
|
}
|
|
13542
13312
|
if (command === "devices") return parseDevices(options);
|
|
13543
|
-
if (command === "completions") {
|
|
13544
|
-
if (options.includes("--help") || options.includes("-h")) {
|
|
13545
|
-
return { command: "help", topic: "completions" };
|
|
13546
|
-
}
|
|
13547
|
-
if (options.length !== 1) {
|
|
13548
|
-
throw new UsageError("Use: glossa completions <shell>.");
|
|
13549
|
-
}
|
|
13550
|
-
const shell = options[0];
|
|
13551
|
-
if (!SUPPORTED_SHELLS.includes(shell)) {
|
|
13552
|
-
throw new UsageError(
|
|
13553
|
-
`Unsupported shell: ${shell}. Use one of: ${SUPPORTED_SHELLS.join(", ")}.`
|
|
13554
|
-
);
|
|
13555
|
-
}
|
|
13556
|
-
return { command: "completions", shell };
|
|
13557
|
-
}
|
|
13558
|
-
if (command === "update" || command === "upgrade") {
|
|
13559
|
-
if (options.includes("--help") || options.includes("-h")) {
|
|
13560
|
-
return { command: "help", topic: "update" };
|
|
13561
|
-
}
|
|
13562
|
-
if (options.length > 0) {
|
|
13563
|
-
throw new UsageError(`${command === "update" ? "Update" : "Upgrade"} accepts no arguments.`);
|
|
13564
|
-
}
|
|
13565
|
-
return { command: "update" };
|
|
13566
|
-
}
|
|
13567
|
-
if (command === "login") {
|
|
13568
|
-
if (options.includes("--help") || options.includes("-h")) {
|
|
13569
|
-
return { command: "help", topic: "login" };
|
|
13570
|
-
}
|
|
13571
|
-
if (options.length > 0) throw new UsageError("Login accepts no arguments.");
|
|
13572
|
-
return { command: "login" };
|
|
13573
|
-
}
|
|
13574
13313
|
if (command === "logout") {
|
|
13575
|
-
|
|
13576
|
-
|
|
13577
|
-
}
|
|
13578
|
-
if (options.length === 0) return { command: "logout", browser: false };
|
|
13579
|
-
if (options.length === 1 && options[0] === "--browser") {
|
|
13580
|
-
return { command: "logout", browser: true };
|
|
13581
|
-
}
|
|
13582
|
-
throw new UsageError("Logout accepts only --browser.");
|
|
13314
|
+
noOptions("Logout", options);
|
|
13315
|
+
return { command };
|
|
13583
13316
|
}
|
|
13584
|
-
|
|
13585
|
-
if (command.startsWith("-")) return parseWorkspaceCommand("start", args);
|
|
13586
|
-
if (likelyDirectory(command)) return parseWorkspaceCommand("start", args);
|
|
13587
|
-
const suggestion = suggestCommand(command);
|
|
13588
|
-
throw new UsageError(
|
|
13589
|
-
suggestion ? `Unknown command: ${command}. Did you mean "${suggestion}"?` : `Unknown command: ${command}`
|
|
13590
|
-
);
|
|
13317
|
+
return parseWorkspace(args);
|
|
13591
13318
|
}
|
|
13592
13319
|
|
|
13593
|
-
// src/device-
|
|
13594
|
-
|
|
13595
|
-
|
|
13596
|
-
|
|
13597
|
-
|
|
13598
|
-
|
|
13599
|
-
parsed = JSON.parse(value);
|
|
13600
|
-
} catch {
|
|
13601
|
-
throw new Error("Stored Glossa device credentials are invalid.");
|
|
13602
|
-
}
|
|
13603
|
-
let relayOriginValid = false;
|
|
13604
|
-
if (typeof parsed.relayOrigin === "string") {
|
|
13605
|
-
try {
|
|
13606
|
-
relayOriginValid = new URL(parsed.relayOrigin).origin === parsed.relayOrigin;
|
|
13607
|
-
} catch {
|
|
13608
|
-
relayOriginValid = false;
|
|
13609
|
-
}
|
|
13610
|
-
}
|
|
13611
|
-
if (!relayOriginValid || typeof parsed.deviceId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
13612
|
-
parsed.deviceId
|
|
13613
|
-
) || typeof parsed.deviceName !== "string" || parsed.deviceName.length === 0 || typeof parsed.token !== "string" || !parsed.token.startsWith(`gld_${parsed.deviceId}_`)) {
|
|
13614
|
-
throw new Error("Stored Glossa device credentials are invalid.");
|
|
13615
|
-
}
|
|
13616
|
-
return parsed;
|
|
13320
|
+
// src/device-format.ts
|
|
13321
|
+
function deviceStatus(device) {
|
|
13322
|
+
if (device.revokedAt) return "revoked";
|
|
13323
|
+
if (device.activeWorkers === null) return "worker count unavailable";
|
|
13324
|
+
if (device.activeWorkers === 0) return "offline";
|
|
13325
|
+
return `${device.activeWorkers} active ${device.activeWorkers === 1 ? "worker" : "workers"}`;
|
|
13617
13326
|
}
|
|
13618
|
-
|
|
13619
|
-
|
|
13620
|
-
|
|
13621
|
-
|
|
13622
|
-
|
|
13623
|
-
|
|
13624
|
-
|
|
13625
|
-
|
|
13327
|
+
function formatRelativeTime(iso, now = Date.now()) {
|
|
13328
|
+
if (!iso) return "never";
|
|
13329
|
+
const parsed = Date.parse(iso);
|
|
13330
|
+
if (!Number.isFinite(parsed)) return "unknown";
|
|
13331
|
+
const seconds = Math.max(0, Math.round((now - parsed) / 1e3));
|
|
13332
|
+
if (seconds < 60) return "just now";
|
|
13333
|
+
const minutes = Math.round(seconds / 60);
|
|
13334
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
13335
|
+
const hours = Math.round(minutes / 60);
|
|
13336
|
+
if (hours < 24) return `${hours}h ago`;
|
|
13337
|
+
const days = Math.round(hours / 24);
|
|
13338
|
+
return `${days}d ago`;
|
|
13626
13339
|
}
|
|
13627
|
-
|
|
13628
|
-
|
|
13340
|
+
function formatDeviceRow(device, now = Date.now()) {
|
|
13341
|
+
const platform = device.platform ?? "unknown platform";
|
|
13342
|
+
return `${device.id} ${device.name} ${platform} last seen ${formatRelativeTime(device.lastSeenAt, now)} ${deviceStatus(device)}`;
|
|
13629
13343
|
}
|
|
13630
|
-
|
|
13631
|
-
|
|
13344
|
+
|
|
13345
|
+
// src/logout.ts
|
|
13346
|
+
function browserLogoutUrl(issuer) {
|
|
13347
|
+
return new URL(
|
|
13348
|
+
"v2/logout",
|
|
13349
|
+
issuer.endsWith("/") ? issuer : `${issuer}/`
|
|
13350
|
+
).toString();
|
|
13632
13351
|
}
|
|
13633
|
-
async function
|
|
13634
|
-
|
|
13352
|
+
async function logoutFromGlossa(dependencies = {}) {
|
|
13353
|
+
const remove = dependencies.deleteCredentials ?? deleteCredentials;
|
|
13354
|
+
const peek = dependencies.peekCredentials ?? peekCredentials;
|
|
13355
|
+
const browse = dependencies.openBrowser ?? openBrowser;
|
|
13356
|
+
const log = dependencies.log ?? console.log;
|
|
13357
|
+
let stored = null;
|
|
13358
|
+
let present = true;
|
|
13359
|
+
try {
|
|
13360
|
+
stored = await peek();
|
|
13361
|
+
present = stored !== null;
|
|
13362
|
+
} catch {
|
|
13363
|
+
}
|
|
13364
|
+
const issuer = dependencies.issuer ?? stored?.credentials.issuer;
|
|
13365
|
+
await remove();
|
|
13366
|
+
log(
|
|
13367
|
+
present ? "Signed out of Glossa." : "Already signed out of Glossa."
|
|
13368
|
+
);
|
|
13369
|
+
const url2 = browserLogoutUrl(issuer ?? loadAuthConfig().issuer);
|
|
13370
|
+
const opened = await browse(url2);
|
|
13371
|
+
if (opened) {
|
|
13372
|
+
log("Opened Glossa browser sign-out.");
|
|
13373
|
+
} else {
|
|
13374
|
+
log("Open this URL to finish signing out in your browser:");
|
|
13375
|
+
log(url2);
|
|
13376
|
+
}
|
|
13635
13377
|
}
|
|
13636
13378
|
|
|
13637
13379
|
// src/relay-client.ts
|
|
@@ -13661,15 +13403,21 @@ function normalizedOrigin(value, kind) {
|
|
|
13661
13403
|
}
|
|
13662
13404
|
return url2.origin;
|
|
13663
13405
|
}
|
|
13664
|
-
function
|
|
13665
|
-
|
|
13406
|
+
function loadRelayOrigin(environment = process.env) {
|
|
13407
|
+
return normalizedOrigin(
|
|
13666
13408
|
environment.GLOSSA_RELAY_ORIGIN?.trim() || DEFAULT_RELAY_ORIGIN,
|
|
13667
13409
|
"relay"
|
|
13668
13410
|
);
|
|
13669
|
-
|
|
13411
|
+
}
|
|
13412
|
+
function loadWorkerOrigin(relayOrigin, environment = process.env) {
|
|
13413
|
+
return normalizedOrigin(
|
|
13670
13414
|
environment.GLOSSA_WORKER_ORIGIN?.trim() || relayOrigin,
|
|
13671
13415
|
"worker"
|
|
13672
13416
|
);
|
|
13417
|
+
}
|
|
13418
|
+
function loadRelayEndpoints(environment = process.env) {
|
|
13419
|
+
const relayOrigin = loadRelayOrigin(environment);
|
|
13420
|
+
const workerOrigin = loadWorkerOrigin(relayOrigin, environment);
|
|
13673
13421
|
return { relayOrigin, workerOrigin };
|
|
13674
13422
|
}
|
|
13675
13423
|
function defaultDeviceName() {
|
|
@@ -13686,7 +13434,9 @@ function relayError(status, data) {
|
|
|
13686
13434
|
);
|
|
13687
13435
|
}
|
|
13688
13436
|
if (status === 409 && data.error === "device_name_conflict") {
|
|
13689
|
-
return new Error(
|
|
13437
|
+
return new Error(
|
|
13438
|
+
"Glossa could not choose a unique name for this computer. Try again."
|
|
13439
|
+
);
|
|
13690
13440
|
}
|
|
13691
13441
|
if (status === 404 && data.error === "device_not_found") {
|
|
13692
13442
|
return new Error("The Glossa device was not found.");
|
|
@@ -13733,44 +13483,48 @@ async function accountOwnsDevice(endpoints, credentials, deviceId, fetchRequest
|
|
|
13733
13483
|
);
|
|
13734
13484
|
}
|
|
13735
13485
|
async function enrollDevice(endpoints, credentials, deviceName, fetchRequest = fetch) {
|
|
13736
|
-
const
|
|
13737
|
-
|
|
13738
|
-
|
|
13739
|
-
|
|
13740
|
-
|
|
13741
|
-
|
|
13742
|
-
|
|
13743
|
-
|
|
13744
|
-
|
|
13745
|
-
|
|
13746
|
-
|
|
13747
|
-
data =
|
|
13748
|
-
|
|
13749
|
-
|
|
13750
|
-
|
|
13751
|
-
|
|
13752
|
-
|
|
13486
|
+
const baseName = deviceNameSchema.parse(deviceName);
|
|
13487
|
+
let name = baseName;
|
|
13488
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
13489
|
+
const response = await fetchRequest(`${endpoints.relayOrigin}/v1/devices/enroll`, {
|
|
13490
|
+
method: "POST",
|
|
13491
|
+
headers: {
|
|
13492
|
+
authorization: `${credentials.tokenType} ${credentials.accessToken}`,
|
|
13493
|
+
"content-type": "application/json"
|
|
13494
|
+
},
|
|
13495
|
+
body: JSON.stringify({ name, platform: `${process.platform}-${process.arch}` })
|
|
13496
|
+
});
|
|
13497
|
+
let data = {};
|
|
13498
|
+
try {
|
|
13499
|
+
data = await response.json();
|
|
13500
|
+
} catch {
|
|
13501
|
+
}
|
|
13502
|
+
if (response.status === 409 && data.error === "device_name_conflict" && attempt < 2) {
|
|
13503
|
+
const activeNames = new Set(
|
|
13504
|
+
(await listDevices(endpoints, credentials, fetchRequest)).filter((device) => device.revokedAt === null).map((device) => device.name)
|
|
13505
|
+
);
|
|
13506
|
+
for (let suffix = 2; ; suffix += 1) {
|
|
13507
|
+
const ending = `-${suffix}`;
|
|
13508
|
+
const candidate = `${baseName.slice(0, 80 - ending.length)}${ending}`;
|
|
13509
|
+
if (!activeNames.has(candidate)) {
|
|
13510
|
+
name = deviceNameSchema.parse(candidate);
|
|
13511
|
+
break;
|
|
13512
|
+
}
|
|
13513
|
+
}
|
|
13514
|
+
continue;
|
|
13515
|
+
}
|
|
13516
|
+
if (!response.ok) throw relayError(response.status, data);
|
|
13517
|
+
if (typeof data.device?.id !== "string" || typeof data.device.name !== "string" || typeof data.device_token !== "string") {
|
|
13518
|
+
throw new Error("The Glossa relay returned an invalid device enrollment response.");
|
|
13519
|
+
}
|
|
13520
|
+
return {
|
|
13521
|
+
relayOrigin: endpoints.relayOrigin,
|
|
13522
|
+
deviceId: data.device.id,
|
|
13523
|
+
deviceName: data.device.name,
|
|
13524
|
+
token: data.device_token
|
|
13525
|
+
};
|
|
13753
13526
|
}
|
|
13754
|
-
|
|
13755
|
-
relayOrigin: endpoints.relayOrigin,
|
|
13756
|
-
deviceId: data.device.id,
|
|
13757
|
-
deviceName: data.device.name,
|
|
13758
|
-
token: data.device_token
|
|
13759
|
-
};
|
|
13760
|
-
}
|
|
13761
|
-
async function renameDevice(endpoints, credentials, deviceId, name, fetchRequest = fetch) {
|
|
13762
|
-
const validName = deviceNameSchema.parse(name);
|
|
13763
|
-
const response = await fetchRequest(`${endpoints.relayOrigin}/v1/devices/${encodeURIComponent(deviceId)}`, {
|
|
13764
|
-
method: "PATCH",
|
|
13765
|
-
headers: {
|
|
13766
|
-
authorization: `${credentials.tokenType} ${credentials.accessToken}`,
|
|
13767
|
-
"content-type": "application/json"
|
|
13768
|
-
},
|
|
13769
|
-
body: JSON.stringify({ name: validName })
|
|
13770
|
-
});
|
|
13771
|
-
const data = await response.json().catch(() => ({}));
|
|
13772
|
-
if (!response.ok) throw relayError(response.status, data);
|
|
13773
|
-
return parseDevices2([data.device])[0];
|
|
13527
|
+
throw new Error("Glossa could not enroll this computer.");
|
|
13774
13528
|
}
|
|
13775
13529
|
async function revokeDevice(endpoints, credentials, deviceId, fetchRequest = fetch) {
|
|
13776
13530
|
const response = await fetchRequest(`${endpoints.relayOrigin}/v1/devices/${encodeURIComponent(deviceId)}`, {
|
|
@@ -13785,671 +13539,331 @@ async function revokeDevice(endpoints, credentials, deviceId, fetchRequest = fet
|
|
|
13785
13539
|
}
|
|
13786
13540
|
}
|
|
13787
13541
|
|
|
13788
|
-
// src/
|
|
13789
|
-
function
|
|
13790
|
-
|
|
13791
|
-
|
|
13792
|
-
|
|
13793
|
-
|
|
13794
|
-
|
|
13795
|
-
|
|
13796
|
-
|
|
13797
|
-
|
|
13798
|
-
var HEALTHZ_TIMEOUT_MS = 5e3;
|
|
13799
|
-
function nodeVersionSatisfies(version2) {
|
|
13800
|
-
const match = /^v?(\d+)\.(\d+)/.exec(version2.trim());
|
|
13801
|
-
if (!match) return false;
|
|
13802
|
-
const major = Number(match[1]);
|
|
13803
|
-
const minor = Number(match[2]);
|
|
13804
|
-
if (!Number.isInteger(major) || !Number.isInteger(minor)) return false;
|
|
13805
|
-
return major > MIN_NODE_MAJOR || major === MIN_NODE_MAJOR && minor >= MIN_NODE_MINOR;
|
|
13806
|
-
}
|
|
13807
|
-
async function runDoctorChecks(dependencies = {}) {
|
|
13808
|
-
const standalone = dependencies.standalone ?? isStandaloneExecutable();
|
|
13809
|
-
const checks = [];
|
|
13810
|
-
if (standalone) {
|
|
13811
|
-
const runtimeReady = await (dependencies.probeStandaloneRuntime ?? defaultProbeStandaloneRuntime)();
|
|
13812
|
-
checks.push({
|
|
13813
|
-
name: "Runtime",
|
|
13814
|
-
status: runtimeReady ? "pass" : "fail",
|
|
13815
|
-
detail: runtimeReady ? "Self-contained Glossa executable." : "The executable is missing its native credential module.",
|
|
13816
|
-
...runtimeReady ? {} : { nextStep: "Reinstall Glossa with npm or the direct installer." }
|
|
13817
|
-
});
|
|
13542
|
+
// src/status-display.ts
|
|
13543
|
+
function formatStatus(status) {
|
|
13544
|
+
const lines = [
|
|
13545
|
+
`Signed in as ${status.account}.`,
|
|
13546
|
+
`Relay connected: ${status.relay}`
|
|
13547
|
+
];
|
|
13548
|
+
if (status.activeWorkers === null) {
|
|
13549
|
+
lines.push("Active workspaces: unavailable");
|
|
13550
|
+
} else if (status.activeWorkers === 0) {
|
|
13551
|
+
lines.push("No active workspaces. Run glossa from the project folder you want to expose.");
|
|
13818
13552
|
} else {
|
|
13819
|
-
|
|
13820
|
-
const nodeOk = nodeVersionSatisfies(nodeVersion);
|
|
13821
|
-
const displayedNodeVersion = nodeVersion.startsWith("v") ? nodeVersion : `v${nodeVersion}`;
|
|
13822
|
-
checks.push({
|
|
13823
|
-
name: "Node.js",
|
|
13824
|
-
status: nodeOk ? "pass" : "fail",
|
|
13825
|
-
detail: `Node.js ${displayedNodeVersion}`,
|
|
13826
|
-
...nodeOk ? {} : { nextStep: `Install Node.js ${MIN_NODE_MAJOR}.${MIN_NODE_MINOR} or newer and restart your terminal.` }
|
|
13827
|
-
});
|
|
13553
|
+
lines.push(`Active workspaces: ${status.activeWorkers}`);
|
|
13828
13554
|
}
|
|
13829
|
-
|
|
13830
|
-
|
|
13555
|
+
if (status.devices.length === 0) lines.push("No active devices.");
|
|
13556
|
+
else lines.push(...status.devices.map((device) => formatDeviceRow(device)));
|
|
13557
|
+
return lines;
|
|
13558
|
+
}
|
|
13559
|
+
|
|
13560
|
+
// src/status-service.ts
|
|
13561
|
+
function accountLabel(profile) {
|
|
13562
|
+
return profile.email ?? profile.name ?? profile.sub;
|
|
13563
|
+
}
|
|
13564
|
+
function activeWorkerCount(devices) {
|
|
13565
|
+
if (devices.some((device) => device.activeWorkers === null)) return null;
|
|
13566
|
+
return devices.reduce((sum, device) => sum + device.activeWorkers, 0);
|
|
13567
|
+
}
|
|
13568
|
+
var WorkspaceStatusService = class {
|
|
13569
|
+
constructor(credentials, endpoints, dependencies = {}) {
|
|
13570
|
+
this.endpoints = endpoints;
|
|
13571
|
+
this.dependencies = dependencies;
|
|
13572
|
+
this.#credentials = credentials;
|
|
13573
|
+
}
|
|
13574
|
+
endpoints;
|
|
13575
|
+
dependencies;
|
|
13576
|
+
#credentials;
|
|
13577
|
+
#inFlight;
|
|
13578
|
+
async refresh(signal) {
|
|
13579
|
+
if (this.#inFlight) return await this.#inFlight;
|
|
13580
|
+
const pending = this.#load(signal);
|
|
13581
|
+
this.#inFlight = pending;
|
|
13831
13582
|
try {
|
|
13832
|
-
|
|
13833
|
-
}
|
|
13834
|
-
|
|
13835
|
-
checks.push({
|
|
13836
|
-
name: "Relay",
|
|
13837
|
-
status: "fail",
|
|
13838
|
-
detail: `Endpoint configuration is invalid: ${message}`,
|
|
13839
|
-
nextStep: "Set GLOSSA_RELAY_ORIGIN and GLOSSA_WORKER_ORIGIN to origin URLs only, without paths."
|
|
13840
|
-
});
|
|
13583
|
+
return await pending;
|
|
13584
|
+
} finally {
|
|
13585
|
+
if (this.#inFlight === pending) this.#inFlight = void 0;
|
|
13841
13586
|
}
|
|
13842
13587
|
}
|
|
13843
|
-
|
|
13844
|
-
const
|
|
13845
|
-
const
|
|
13846
|
-
|
|
13847
|
-
|
|
13848
|
-
|
|
13849
|
-
|
|
13850
|
-
|
|
13588
|
+
async #load(signal) {
|
|
13589
|
+
const validate = this.dependencies.validCredentials ?? validCredentials;
|
|
13590
|
+
const devicesForAccount = this.dependencies.listDevices ?? listDevices;
|
|
13591
|
+
const profileForAccount = this.dependencies.loadUserProfile ?? loadUserProfile;
|
|
13592
|
+
const baseFetch = this.dependencies.fetch ?? fetch;
|
|
13593
|
+
const fetchRequest = signal ? async (input, init) => await baseFetch(input, { ...init, signal }) : baseFetch;
|
|
13594
|
+
this.#credentials = await validate(
|
|
13595
|
+
this.#credentials,
|
|
13596
|
+
signal ? { signal } : {}
|
|
13597
|
+
);
|
|
13598
|
+
const requestCredentials = this.#credentials;
|
|
13599
|
+
const profileRequest = profileForAccount(
|
|
13600
|
+
requestCredentials,
|
|
13601
|
+
signal ? { signal, fetch: fetchRequest } : { fetch: fetchRequest }
|
|
13602
|
+
).catch((error46) => {
|
|
13603
|
+
if (signal?.aborted) throw error46;
|
|
13604
|
+
return null;
|
|
13851
13605
|
});
|
|
13852
|
-
|
|
13853
|
-
|
|
13854
|
-
|
|
13855
|
-
|
|
13856
|
-
|
|
13857
|
-
|
|
13858
|
-
|
|
13859
|
-
|
|
13860
|
-
|
|
13861
|
-
|
|
13862
|
-
|
|
13863
|
-
const credentialState = await probeCredentials();
|
|
13864
|
-
checks.push(signInCheck(credentialState));
|
|
13865
|
-
const probeDeviceCredential = dependencies.probeDeviceCredential ?? defaultProbeDeviceCredential;
|
|
13866
|
-
const deviceCredentialState = await probeDeviceCredential();
|
|
13867
|
-
checks.push(deviceCredentialCheck(deviceCredentialState));
|
|
13868
|
-
return checks;
|
|
13869
|
-
}
|
|
13870
|
-
async function defaultProbeStandaloneRuntime() {
|
|
13871
|
-
try {
|
|
13872
|
-
const keyring = await import("@napi-rs/keyring");
|
|
13873
|
-
return typeof keyring.AsyncEntry === "function";
|
|
13874
|
-
} catch {
|
|
13875
|
-
return false;
|
|
13876
|
-
}
|
|
13877
|
-
}
|
|
13878
|
-
function signInCheck(state) {
|
|
13879
|
-
if (state === "present") {
|
|
13880
|
-
return { name: "Sign-in", status: "pass", detail: "Signed in to Glossa." };
|
|
13881
|
-
}
|
|
13882
|
-
if (state === "absent") {
|
|
13606
|
+
const devicesRequest = devicesForAccount(
|
|
13607
|
+
this.endpoints,
|
|
13608
|
+
requestCredentials,
|
|
13609
|
+
fetchRequest
|
|
13610
|
+
);
|
|
13611
|
+
const [profile, devices] = await Promise.all([
|
|
13612
|
+
profileRequest,
|
|
13613
|
+
devicesRequest
|
|
13614
|
+
]);
|
|
13615
|
+
if (profile) this.#credentials = profile.credentials;
|
|
13616
|
+
const activeDevices = devices.filter((device) => device.revokedAt === null);
|
|
13883
13617
|
return {
|
|
13884
|
-
|
|
13885
|
-
|
|
13886
|
-
|
|
13887
|
-
|
|
13618
|
+
account: profile ? accountLabel(profile.profile) : "Account unavailable",
|
|
13619
|
+
relay: this.endpoints.relayOrigin,
|
|
13620
|
+
activeWorkers: activeWorkerCount(activeDevices),
|
|
13621
|
+
devices: activeDevices
|
|
13888
13622
|
};
|
|
13889
13623
|
}
|
|
13890
|
-
|
|
13891
|
-
|
|
13892
|
-
|
|
13893
|
-
|
|
13894
|
-
|
|
13895
|
-
|
|
13624
|
+
};
|
|
13625
|
+
|
|
13626
|
+
// src/ui-hud.ts
|
|
13627
|
+
import { emitKeypressEvents } from "node:readline";
|
|
13628
|
+
|
|
13629
|
+
// src/first-run.ts
|
|
13630
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
13631
|
+
import path3 from "node:path";
|
|
13632
|
+
var CONNECT_HINT_FILE = "connect-hint-shown";
|
|
13633
|
+
var CONNECT_HINT_URL = "https://glossa.sh/docs/quickstart";
|
|
13634
|
+
function shouldShowConnectHint(relayOrigin) {
|
|
13635
|
+
return relayOrigin === DEFAULT_RELAY_ORIGIN;
|
|
13896
13636
|
}
|
|
13897
|
-
function
|
|
13898
|
-
|
|
13899
|
-
return {
|
|
13900
|
-
name: "Device",
|
|
13901
|
-
status: "pass",
|
|
13902
|
-
detail: "Device credentials are readable."
|
|
13903
|
-
};
|
|
13904
|
-
}
|
|
13905
|
-
if (state === "absent") {
|
|
13906
|
-
return {
|
|
13907
|
-
name: "Device",
|
|
13908
|
-
status: "warn",
|
|
13909
|
-
detail: "No device is enrolled on this computer yet.",
|
|
13910
|
-
nextStep: 'Run "glossa --device-name <name> ." inside a workspace to enroll it.'
|
|
13911
|
-
};
|
|
13912
|
-
}
|
|
13637
|
+
function connectHintStore(directory = configDirectory()) {
|
|
13638
|
+
const file2 = path3.join(directory, CONNECT_HINT_FILE);
|
|
13913
13639
|
return {
|
|
13914
|
-
|
|
13915
|
-
|
|
13916
|
-
|
|
13917
|
-
|
|
13918
|
-
|
|
13919
|
-
|
|
13920
|
-
|
|
13921
|
-
|
|
13922
|
-
|
|
13923
|
-
|
|
13924
|
-
|
|
13925
|
-
|
|
13926
|
-
lines.push(` ${name} ${check2.status.toUpperCase()} ${check2.detail}`);
|
|
13927
|
-
if (check2.nextStep) {
|
|
13928
|
-
lines.push(` ${" ".repeat(nameWidth)} ${check2.nextStep}`);
|
|
13640
|
+
async exists() {
|
|
13641
|
+
try {
|
|
13642
|
+
await readFile2(file2, "utf8");
|
|
13643
|
+
return true;
|
|
13644
|
+
} catch (error46) {
|
|
13645
|
+
if (error46.code === "ENOENT") return false;
|
|
13646
|
+
throw error46;
|
|
13647
|
+
}
|
|
13648
|
+
},
|
|
13649
|
+
async mark() {
|
|
13650
|
+
await mkdir2(directory, { recursive: true, mode: 448 });
|
|
13651
|
+
await writeFile2(file2, "", { encoding: "utf8", mode: 384 });
|
|
13929
13652
|
}
|
|
13930
|
-
}
|
|
13931
|
-
const failed = checks.filter((check2) => check2.status === "fail").length;
|
|
13932
|
-
lines.push("");
|
|
13933
|
-
lines.push(
|
|
13934
|
-
failed === 0 ? "Glossa is ready to start." : `${failed} check${failed === 1 ? "" : "s"} failed. Resolve the items above before starting.`
|
|
13935
|
-
);
|
|
13936
|
-
return lines.join("\n");
|
|
13653
|
+
};
|
|
13937
13654
|
}
|
|
13938
|
-
async function
|
|
13939
|
-
|
|
13940
|
-
log(
|
|
13941
|
-
|
|
13655
|
+
async function announceConnectHint(store3, log) {
|
|
13656
|
+
if (await store3.exists()) return false;
|
|
13657
|
+
log(`Next: add Glossa in ChatGPT. Follow the quickstart at ${CONNECT_HINT_URL}.`);
|
|
13658
|
+
await store3.mark();
|
|
13659
|
+
return true;
|
|
13942
13660
|
}
|
|
13943
|
-
|
|
13661
|
+
|
|
13662
|
+
// src/device-store.ts
|
|
13663
|
+
import path4 from "node:path";
|
|
13664
|
+
var FILE_DEVICE_WARNING = "Warning: the operating-system credential store is unavailable. Glossa is using a mode-0600 device credential file.";
|
|
13665
|
+
function parseDeviceCredential(value) {
|
|
13666
|
+
let parsed;
|
|
13944
13667
|
try {
|
|
13945
|
-
|
|
13946
|
-
signal: AbortSignal.timeout(HEALTHZ_TIMEOUT_MS)
|
|
13947
|
-
});
|
|
13948
|
-
if (!response.ok) return false;
|
|
13949
|
-
const data = await response.json();
|
|
13950
|
-
return data.ok === true && data.service === "glossa-relay";
|
|
13668
|
+
parsed = JSON.parse(value);
|
|
13951
13669
|
} catch {
|
|
13952
|
-
|
|
13670
|
+
throw new Error("Stored Glossa device credentials are invalid.");
|
|
13953
13671
|
}
|
|
13954
|
-
|
|
13955
|
-
|
|
13956
|
-
|
|
13957
|
-
|
|
13958
|
-
|
|
13959
|
-
|
|
13672
|
+
let relayOriginValid = false;
|
|
13673
|
+
if (typeof parsed.relayOrigin === "string") {
|
|
13674
|
+
try {
|
|
13675
|
+
relayOriginValid = new URL(parsed.relayOrigin).origin === parsed.relayOrigin;
|
|
13676
|
+
} catch {
|
|
13677
|
+
relayOriginValid = false;
|
|
13678
|
+
}
|
|
13960
13679
|
}
|
|
13961
|
-
}
|
|
13962
|
-
|
|
13963
|
-
|
|
13964
|
-
|
|
13965
|
-
} catch {
|
|
13966
|
-
return "error";
|
|
13680
|
+
if (!relayOriginValid || typeof parsed.deviceId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
13681
|
+
parsed.deviceId
|
|
13682
|
+
) || typeof parsed.deviceName !== "string" || parsed.deviceName.length === 0 || parsed.accountSubject !== void 0 && (typeof parsed.accountSubject !== "string" || parsed.accountSubject.length === 0) || typeof parsed.token !== "string" || !parsed.token.startsWith(`gld_${parsed.deviceId}_`)) {
|
|
13683
|
+
throw new Error("Stored Glossa device credentials are invalid.");
|
|
13967
13684
|
}
|
|
13685
|
+
return parsed;
|
|
13968
13686
|
}
|
|
13969
|
-
|
|
13970
|
-
|
|
13971
|
-
|
|
13972
|
-
|
|
13973
|
-
|
|
13974
|
-
|
|
13975
|
-
|
|
13687
|
+
var store2 = new SecureStore({
|
|
13688
|
+
account: "device",
|
|
13689
|
+
file: path4.join(configDirectory(), "device.json"),
|
|
13690
|
+
warning: FILE_DEVICE_WARNING,
|
|
13691
|
+
parse: parseDeviceCredential
|
|
13692
|
+
});
|
|
13693
|
+
async function loadDeviceCredential() {
|
|
13694
|
+
return (await store2.load())?.value ?? null;
|
|
13976
13695
|
}
|
|
13977
|
-
function
|
|
13978
|
-
|
|
13979
|
-
const parsed = Date.parse(iso);
|
|
13980
|
-
if (!Number.isFinite(parsed)) return "unknown";
|
|
13981
|
-
const seconds = Math.max(0, Math.round((now - parsed) / 1e3));
|
|
13982
|
-
if (seconds < 60) return "just now";
|
|
13983
|
-
const minutes = Math.round(seconds / 60);
|
|
13984
|
-
if (minutes < 60) return `${minutes}m ago`;
|
|
13985
|
-
const hours = Math.round(minutes / 60);
|
|
13986
|
-
if (hours < 24) return `${hours}h ago`;
|
|
13987
|
-
const days = Math.round(hours / 24);
|
|
13988
|
-
return `${days}d ago`;
|
|
13696
|
+
async function saveDeviceCredential(credential) {
|
|
13697
|
+
await store2.save(credential);
|
|
13989
13698
|
}
|
|
13990
|
-
function
|
|
13991
|
-
|
|
13992
|
-
return `${device.id} ${device.name} ${platform} last seen ${formatRelativeTime(device.lastSeenAt, now)} ${deviceStatus(device)}`;
|
|
13699
|
+
async function deleteDeviceCredential() {
|
|
13700
|
+
await store2.delete();
|
|
13993
13701
|
}
|
|
13994
13702
|
|
|
13995
|
-
// src/
|
|
13996
|
-
|
|
13997
|
-
|
|
13998
|
-
|
|
13999
|
-
|
|
14000
|
-
).toString();
|
|
14001
|
-
}
|
|
14002
|
-
async function logoutFromGlossa(options, dependencies = {}) {
|
|
14003
|
-
const remove = dependencies.deleteCredentials ?? deleteCredentials;
|
|
14004
|
-
const peek = dependencies.peekCredentials ?? peekCredentials;
|
|
14005
|
-
const browse = dependencies.openBrowser ?? openBrowser;
|
|
14006
|
-
const log = dependencies.log ?? console.log;
|
|
14007
|
-
let stored = null;
|
|
14008
|
-
let present = true;
|
|
14009
|
-
try {
|
|
14010
|
-
stored = await peek();
|
|
14011
|
-
present = stored !== null;
|
|
14012
|
-
} catch {
|
|
14013
|
-
}
|
|
14014
|
-
const issuer = dependencies.issuer ?? stored?.credentials.issuer;
|
|
14015
|
-
await remove();
|
|
14016
|
-
log(
|
|
14017
|
-
present ? "Signed out of Glossa locally." : "Already signed out of Glossa locally."
|
|
14018
|
-
);
|
|
14019
|
-
if (!options.browser) return;
|
|
14020
|
-
const url2 = browserLogoutUrl(issuer ?? loadAuthConfig().issuer);
|
|
14021
|
-
const opened = await browse(url2);
|
|
14022
|
-
if (opened) {
|
|
14023
|
-
log("Opened Glossa browser sign-out.");
|
|
14024
|
-
} else {
|
|
14025
|
-
log("Open this URL to finish signing out in your browser:");
|
|
14026
|
-
log(url2);
|
|
14027
|
-
}
|
|
14028
|
-
log(
|
|
14029
|
-
"Reconnect Glossa in ChatGPT, then choose the same Google account when the CLI signs in."
|
|
14030
|
-
);
|
|
14031
|
-
}
|
|
13703
|
+
// src/worker/command-service.ts
|
|
13704
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
13705
|
+
import { randomUUID } from "node:crypto";
|
|
13706
|
+
import { StringDecoder } from "node:string_decoder";
|
|
13707
|
+
import { setTimeout as delay2 } from "node:timers/promises";
|
|
14032
13708
|
|
|
14033
|
-
// src/
|
|
14034
|
-
|
|
14035
|
-
|
|
14036
|
-
|
|
14037
|
-
|
|
13709
|
+
// src/worker/errors.ts
|
|
13710
|
+
var WorkerError = class extends Error {
|
|
13711
|
+
constructor(code, message) {
|
|
13712
|
+
super(message);
|
|
13713
|
+
this.code = code;
|
|
13714
|
+
this.name = "WorkerError";
|
|
14038
13715
|
}
|
|
14039
|
-
|
|
14040
|
-
}
|
|
13716
|
+
code;
|
|
13717
|
+
};
|
|
14041
13718
|
|
|
14042
|
-
// src/
|
|
14043
|
-
|
|
14044
|
-
|
|
14045
|
-
|
|
14046
|
-
|
|
14047
|
-
|
|
14048
|
-
|
|
14049
|
-
|
|
14050
|
-
|
|
14051
|
-
}
|
|
14052
|
-
|
|
14053
|
-
|
|
14054
|
-
|
|
14055
|
-
|
|
14056
|
-
|
|
14057
|
-
|
|
14058
|
-
|
|
14059
|
-
|
|
14060
|
-
}
|
|
14061
|
-
return {
|
|
14062
|
-
command: "npm",
|
|
14063
|
-
args: ["install", "--global", NPM_PACKAGE]
|
|
14064
|
-
};
|
|
14065
|
-
}
|
|
14066
|
-
function standaloneAssetName(platform = process.platform, architecture = process.arch) {
|
|
14067
|
-
const operatingSystem = platform === "win32" ? "windows" : platform === "darwin" ? "macos" : platform === "linux" ? "linux" : null;
|
|
14068
|
-
if (!operatingSystem || !["x64", "arm64"].includes(architecture)) {
|
|
14069
|
-
throw new Error(
|
|
14070
|
-
`The direct Glossa installer does not support ${platform}/${architecture}. Use npm instead.`
|
|
14071
|
-
);
|
|
14072
|
-
}
|
|
14073
|
-
return `glossa-${operatingSystem}-${architecture}${platform === "win32" ? ".exe" : ""}`;
|
|
14074
|
-
}
|
|
14075
|
-
function selectStandaloneRelease(value, assetName) {
|
|
14076
|
-
if (!Array.isArray(value)) {
|
|
14077
|
-
throw new Error("The Glossa release service returned an invalid response.");
|
|
14078
|
-
}
|
|
14079
|
-
for (const candidate of value) {
|
|
14080
|
-
if (candidate.draft === true || typeof candidate.tag_name !== "string") {
|
|
14081
|
-
continue;
|
|
14082
|
-
}
|
|
14083
|
-
if (!candidate.tag_name.startsWith("cli-v") || !Array.isArray(candidate.assets)) {
|
|
14084
|
-
continue;
|
|
14085
|
-
}
|
|
14086
|
-
const assets = candidate.assets;
|
|
14087
|
-
const binary = assets.find((asset) => asset.name === assetName);
|
|
14088
|
-
const checksum = assets.find(
|
|
14089
|
-
(asset) => asset.name === `${assetName}.sha256`
|
|
13719
|
+
// src/worker/command-service.ts
|
|
13720
|
+
var STREAM_HEAD_BYTES = Math.floor(MAX_COMMAND_OUTPUT_BYTES / 3);
|
|
13721
|
+
var STREAM_TAIL_BYTES = MAX_COMMAND_OUTPUT_BYTES - STREAM_HEAD_BYTES;
|
|
13722
|
+
function appendTail(existing, chunk) {
|
|
13723
|
+
if (chunk.byteLength >= STREAM_TAIL_BYTES) {
|
|
13724
|
+
return Buffer.from(chunk.subarray(chunk.byteLength - STREAM_TAIL_BYTES));
|
|
13725
|
+
}
|
|
13726
|
+
const combined = existing.byteLength === 0 ? chunk : Buffer.concat([existing, chunk]);
|
|
13727
|
+
return combined.byteLength <= STREAM_TAIL_BYTES ? combined : combined.subarray(combined.byteLength - STREAM_TAIL_BYTES);
|
|
13728
|
+
}
|
|
13729
|
+
function capture(_record2, stream, chunk) {
|
|
13730
|
+
if (chunk.byteLength === 0) return false;
|
|
13731
|
+
stream.totalBytes += chunk.byteLength;
|
|
13732
|
+
let offset = 0;
|
|
13733
|
+
if (stream.headBytes < STREAM_HEAD_BYTES) {
|
|
13734
|
+
const accepted = chunk.subarray(
|
|
13735
|
+
0,
|
|
13736
|
+
Math.min(chunk.byteLength, STREAM_HEAD_BYTES - stream.headBytes)
|
|
14090
13737
|
);
|
|
14091
|
-
if (
|
|
14092
|
-
|
|
14093
|
-
|
|
14094
|
-
|
|
14095
|
-
checksumUrl: checksum.browser_download_url
|
|
14096
|
-
};
|
|
13738
|
+
if (accepted.byteLength > 0) {
|
|
13739
|
+
stream.head.push(Buffer.from(accepted));
|
|
13740
|
+
stream.headBytes += accepted.byteLength;
|
|
13741
|
+
offset = accepted.byteLength;
|
|
14097
13742
|
}
|
|
14098
13743
|
}
|
|
14099
|
-
|
|
14100
|
-
|
|
14101
|
-
);
|
|
14102
|
-
}
|
|
14103
|
-
function expectedChecksum(text, assetName) {
|
|
14104
|
-
for (const line of text.split(/\r?\n/)) {
|
|
14105
|
-
const match = /^([a-fA-F0-9]{64})\s+\*?(.+)$/.exec(line.trim());
|
|
14106
|
-
if (match?.[1] && match[2] === assetName) return match[1].toLowerCase();
|
|
13744
|
+
if (offset < chunk.byteLength) {
|
|
13745
|
+
stream.tail = appendTail(stream.tail, chunk.subarray(offset));
|
|
14107
13746
|
}
|
|
14108
|
-
|
|
13747
|
+
return true;
|
|
14109
13748
|
}
|
|
14110
|
-
|
|
14111
|
-
|
|
14112
|
-
|
|
14113
|
-
|
|
14114
|
-
|
|
14115
|
-
|
|
14116
|
-
|
|
14117
|
-
|
|
14118
|
-
|
|
14119
|
-
async function updateStandalone(dependencies) {
|
|
14120
|
-
const platform = dependencies.platform ?? process.platform;
|
|
14121
|
-
const architecture = dependencies.architecture ?? process.arch;
|
|
14122
|
-
const environment = dependencies.environment ?? process.env;
|
|
14123
|
-
const executablePath = dependencies.executablePath ?? process.execPath;
|
|
14124
|
-
const fetcher = dependencies.fetch ?? fetch;
|
|
14125
|
-
const assetName = standaloneAssetName(platform, architecture);
|
|
14126
|
-
const releasesUrl = environment.GLOSSA_RELEASES_API ?? DEFAULT_RELEASES_API;
|
|
14127
|
-
const releasesResponse = await checkedFetch(fetcher, releasesUrl);
|
|
14128
|
-
const release = selectStandaloneRelease(
|
|
14129
|
-
await releasesResponse.json(),
|
|
14130
|
-
assetName
|
|
14131
|
-
);
|
|
14132
|
-
if (release.version === dependencies.currentVersion) {
|
|
14133
|
-
return `Glossa ${release.version} is already current.`;
|
|
14134
|
-
}
|
|
14135
|
-
const [binaryResponse, checksumResponse] = await Promise.all([
|
|
14136
|
-
checkedFetch(fetcher, release.binaryUrl),
|
|
14137
|
-
checkedFetch(fetcher, release.checksumUrl)
|
|
14138
|
-
]);
|
|
14139
|
-
const binary = Buffer.from(await binaryResponse.arrayBuffer());
|
|
14140
|
-
const expected = expectedChecksum(await checksumResponse.text(), assetName);
|
|
14141
|
-
const actual = createHash("sha256").update(binary).digest("hex");
|
|
14142
|
-
if (actual !== expected) {
|
|
14143
|
-
throw new Error(
|
|
14144
|
-
`Glossa refused the update because the SHA-256 checksum did not match.`
|
|
14145
|
-
);
|
|
13749
|
+
function markChanged(record2) {
|
|
13750
|
+
record2.sequence += 1;
|
|
13751
|
+
const waiters = [...record2.changeWaiters];
|
|
13752
|
+
record2.changeWaiters.clear();
|
|
13753
|
+
for (const waiter of waiters) waiter();
|
|
13754
|
+
}
|
|
13755
|
+
async function waitForChange(record2, afterSequence, waitMs) {
|
|
13756
|
+
if (record2.status !== "running" || record2.sequence > afterSequence || waitMs === 0) {
|
|
13757
|
+
return;
|
|
14146
13758
|
}
|
|
14147
|
-
|
|
14148
|
-
const
|
|
14149
|
-
|
|
14150
|
-
|
|
13759
|
+
let changed;
|
|
13760
|
+
const change = new Promise((resolve) => {
|
|
13761
|
+
changed = resolve;
|
|
13762
|
+
record2.changeWaiters.add(changed);
|
|
13763
|
+
});
|
|
13764
|
+
const waitController = new AbortController();
|
|
14151
13765
|
try {
|
|
14152
|
-
|
|
14153
|
-
|
|
14154
|
-
|
|
14155
|
-
|
|
14156
|
-
renameSync(downloadPath, executablePath);
|
|
14157
|
-
} catch (error46) {
|
|
14158
|
-
renameSync(backupPath, executablePath);
|
|
14159
|
-
throw error46;
|
|
14160
|
-
}
|
|
14161
|
-
try {
|
|
14162
|
-
rmSync(backupPath, { force: true });
|
|
14163
|
-
} catch {
|
|
14164
|
-
}
|
|
14165
|
-
} else {
|
|
14166
|
-
renameSync(downloadPath, executablePath);
|
|
14167
|
-
}
|
|
13766
|
+
await Promise.race([
|
|
13767
|
+
change,
|
|
13768
|
+
delay2(waitMs, void 0, { signal: waitController.signal })
|
|
13769
|
+
]);
|
|
14168
13770
|
} finally {
|
|
14169
|
-
|
|
13771
|
+
record2.changeWaiters.delete(changed);
|
|
13772
|
+
waitController.abort();
|
|
14170
13773
|
}
|
|
14171
|
-
return `Updated Glossa to ${release.version}.`;
|
|
14172
13774
|
}
|
|
14173
|
-
|
|
14174
|
-
|
|
14175
|
-
|
|
14176
|
-
|
|
14177
|
-
|
|
14178
|
-
|
|
14179
|
-
|
|
14180
|
-
|
|
13775
|
+
function emptyCapture() {
|
|
13776
|
+
return {
|
|
13777
|
+
head: [],
|
|
13778
|
+
headBytes: 0,
|
|
13779
|
+
tail: Buffer.alloc(0),
|
|
13780
|
+
totalBytes: 0
|
|
13781
|
+
};
|
|
13782
|
+
}
|
|
13783
|
+
function retainedBytes(stream, complete) {
|
|
13784
|
+
const head = Buffer.concat(stream.head, stream.headBytes);
|
|
13785
|
+
const retained = Buffer.concat([head, stream.tail]);
|
|
13786
|
+
const content = stream.totalBytes <= MAX_COMMAND_OUTPUT_BYTES ? complete ? retained.toString("utf8") : new StringDecoder("utf8").write(retained) : safePrefix(head) + safeSuffix(stream.tail);
|
|
13787
|
+
return Math.min(Buffer.byteLength(content), MAX_COMMAND_OUTPUT_BYTES);
|
|
13788
|
+
}
|
|
13789
|
+
function safePrefix(buffer) {
|
|
13790
|
+
return new StringDecoder("utf8").write(buffer);
|
|
13791
|
+
}
|
|
13792
|
+
function safeSuffix(buffer) {
|
|
13793
|
+
let start = 0;
|
|
13794
|
+
while (start < buffer.byteLength && (buffer[start] & 192) === 128) {
|
|
13795
|
+
start += 1;
|
|
13796
|
+
}
|
|
13797
|
+
return new StringDecoder("utf8").write(buffer.subarray(start));
|
|
13798
|
+
}
|
|
13799
|
+
function utf8PrefixWithinBudget(value, budget) {
|
|
13800
|
+
let used = 0;
|
|
13801
|
+
let end = 0;
|
|
13802
|
+
for (const character of value) {
|
|
13803
|
+
const bytes = Buffer.byteLength(character);
|
|
13804
|
+
if (used + bytes > budget) break;
|
|
13805
|
+
used += bytes;
|
|
13806
|
+
end += character.length;
|
|
13807
|
+
}
|
|
13808
|
+
return value.slice(0, end);
|
|
13809
|
+
}
|
|
13810
|
+
function utf8SuffixWithinBudget(value, budget) {
|
|
13811
|
+
const characters = Array.from(value);
|
|
13812
|
+
let used = 0;
|
|
13813
|
+
let start = characters.length;
|
|
13814
|
+
while (start > 0) {
|
|
13815
|
+
const bytes = Buffer.byteLength(characters[start - 1]);
|
|
13816
|
+
if (used + bytes > budget) break;
|
|
13817
|
+
used += bytes;
|
|
13818
|
+
start -= 1;
|
|
13819
|
+
}
|
|
13820
|
+
return characters.slice(start).join("");
|
|
13821
|
+
}
|
|
13822
|
+
function renderStream(stream, budget, complete) {
|
|
13823
|
+
if (budget <= 0 || stream.totalBytes === 0) {
|
|
13824
|
+
return { content: "", truncated: stream.totalBytes > 0 };
|
|
13825
|
+
}
|
|
13826
|
+
const head = Buffer.concat(stream.head, stream.headBytes);
|
|
13827
|
+
const retained = Buffer.concat([head, stream.tail]);
|
|
13828
|
+
if (stream.totalBytes <= budget) {
|
|
13829
|
+
const content = complete ? retained.toString("utf8") : new StringDecoder("utf8").write(retained);
|
|
13830
|
+
if (Buffer.byteLength(content) <= budget) {
|
|
13831
|
+
return { content, truncated: false };
|
|
13832
|
+
}
|
|
13833
|
+
const prefixBudget2 = Math.floor(budget / 3);
|
|
13834
|
+
return {
|
|
13835
|
+
content: utf8PrefixWithinBudget(content, prefixBudget2) + utf8SuffixWithinBudget(content, budget - prefixBudget2),
|
|
13836
|
+
truncated: true
|
|
13837
|
+
};
|
|
14181
13838
|
}
|
|
14182
|
-
const
|
|
14183
|
-
const
|
|
14184
|
-
|
|
14185
|
-
|
|
14186
|
-
|
|
14187
|
-
|
|
14188
|
-
const
|
|
14189
|
-
if (result.error) {
|
|
14190
|
-
throw new Error(`Glossa could not start npm: ${result.error.message}`);
|
|
14191
|
-
}
|
|
14192
|
-
if (result.status !== 0) {
|
|
14193
|
-
throw new Error(
|
|
14194
|
-
`npm could not update Glossa (exit ${result.status ?? "unknown"}).`
|
|
14195
|
-
);
|
|
14196
|
-
}
|
|
14197
|
-
log("Glossa updated. Run glossa --version to verify the installed version.");
|
|
14198
|
-
}
|
|
14199
|
-
|
|
14200
|
-
// src/ui-hud.ts
|
|
14201
|
-
import { emitKeypressEvents } from "node:readline";
|
|
14202
|
-
function initialHudState(workspace) {
|
|
13839
|
+
const headBudget = Math.min(head.byteLength, Math.floor(budget / 3));
|
|
13840
|
+
const tailBudget = Math.min(stream.tail.byteLength, budget - headBudget);
|
|
13841
|
+
const remaining = budget - headBudget - tailBudget;
|
|
13842
|
+
const extraHead = Math.min(remaining, head.byteLength - headBudget);
|
|
13843
|
+
const prefixBudget = headBudget + extraHead;
|
|
13844
|
+
const prefix = head.subarray(0, headBudget + extraHead);
|
|
13845
|
+
const suffix = stream.tail.subarray(stream.tail.byteLength - tailBudget);
|
|
14203
13846
|
return {
|
|
14204
|
-
|
|
14205
|
-
|
|
14206
|
-
|
|
14207
|
-
|
|
14208
|
-
|
|
14209
|
-
|
|
14210
|
-
|
|
14211
|
-
|
|
14212
|
-
|
|
14213
|
-
|
|
14214
|
-
|
|
14215
|
-
|
|
14216
|
-
|
|
14217
|
-
|
|
14218
|
-
|
|
14219
|
-
if (event.type === "session") {
|
|
14220
|
-
return { ...state, workspace: event.root, deviceName: event.deviceName };
|
|
14221
|
-
}
|
|
14222
|
-
if (event.type === "status") {
|
|
14223
|
-
if (event.status.state === "retrying") {
|
|
14224
|
-
return { ...state, connection: "retrying", message: event.status.error.message };
|
|
14225
|
-
}
|
|
14226
|
-
return { ...state, connection: event.status.state, message: void 0 };
|
|
14227
|
-
}
|
|
14228
|
-
if (event.type === "notice") return { ...state, message: event.message };
|
|
14229
|
-
const activity = event.phase === "finished" ? { label: activityLabel(event), requestId: event.requestId, ok: event.ok } : { label: activityLabel(event), requestId: event.requestId };
|
|
14230
|
-
return { ...state, activities: [...state.activities.slice(-7), activity] };
|
|
14231
|
-
}
|
|
14232
|
-
function style(enabled, code, value) {
|
|
14233
|
-
return enabled ? `\x1B[${code}m${value}\x1B[0m` : value;
|
|
14234
|
-
}
|
|
14235
|
-
function renderTitle(width, color) {
|
|
14236
|
-
const title = "Glossa";
|
|
14237
|
-
const padding = " ".repeat(Math.max(0, Math.floor((width - title.length) / 2)));
|
|
14238
|
-
return `${padding}${style(color, "38;2;120;77;250;1", title)}`;
|
|
14239
|
-
}
|
|
14240
|
-
function truncate(value, width) {
|
|
14241
|
-
if (value.length <= width) return value;
|
|
14242
|
-
if (width <= 1) return "\u2026";
|
|
14243
|
-
return `${value.slice(0, width - 1)}\u2026`;
|
|
14244
|
-
}
|
|
14245
|
-
function wrapText(value, width) {
|
|
14246
|
-
const words = value.split(/\s+/);
|
|
14247
|
-
const lines = [];
|
|
14248
|
-
let line = "";
|
|
14249
|
-
for (const word of words) {
|
|
14250
|
-
if (!line) line = word;
|
|
14251
|
-
else if (`${line} ${word}`.length <= width) line += ` ${word}`;
|
|
14252
|
-
else {
|
|
14253
|
-
lines.push(line);
|
|
14254
|
-
line = word;
|
|
14255
|
-
}
|
|
14256
|
-
}
|
|
14257
|
-
if (line) lines.push(line);
|
|
14258
|
-
return lines;
|
|
14259
|
-
}
|
|
14260
|
-
function connectionCopy(state) {
|
|
14261
|
-
if (state.connection === "connected") {
|
|
14262
|
-
return {
|
|
14263
|
-
glyph: "\u25CF",
|
|
14264
|
-
label: "Connected",
|
|
14265
|
-
detail: state.message ?? "ChatGPT can use this workspace."
|
|
14266
|
-
};
|
|
14267
|
-
}
|
|
14268
|
-
if (state.connection === "connecting" || state.connection === "starting") {
|
|
14269
|
-
return { glyph: "\u25CC", label: "Connecting", detail: "Establishing the managed relay session\u2026" };
|
|
14270
|
-
}
|
|
14271
|
-
if (state.connection === "retrying") {
|
|
14272
|
-
return { glyph: "\u25CC", label: "Reconnecting", detail: state.message ?? "Retrying automatically\u2026" };
|
|
14273
|
-
}
|
|
14274
|
-
if (state.connection === "error") {
|
|
14275
|
-
return { glyph: "\xD7", label: "Error", detail: state.message ?? "The session stopped unexpectedly." };
|
|
14276
|
-
}
|
|
14277
|
-
return { glyph: "\u25CB", label: "Disconnected", detail: "The workspace is no longer exposed." };
|
|
14278
|
-
}
|
|
14279
|
-
function renderHud(state, width = 80, color = !process.env.NO_COLOR) {
|
|
14280
|
-
const copy = connectionCopy(state);
|
|
14281
|
-
const usable = Math.max(24, width - 4);
|
|
14282
|
-
const lines = [
|
|
14283
|
-
renderTitle(width, color),
|
|
14284
|
-
"",
|
|
14285
|
-
`${style(color, state.connection === "connected" ? "32;1" : "36;1", copy.glyph)} ${style(color, "1", copy.label)}`,
|
|
14286
|
-
` ${truncate(copy.detail, usable)}`,
|
|
14287
|
-
"",
|
|
14288
|
-
`${style(color, "2", "Workspace")} ${truncate(state.workspace, Math.max(8, usable - 11))}`,
|
|
14289
|
-
"",
|
|
14290
|
-
style(color, "1", "Authority"),
|
|
14291
|
-
...wrapText(
|
|
14292
|
-
"Files may be modified and commands have the full environment and permissions of this account.",
|
|
14293
|
-
usable
|
|
14294
|
-
).map((line) => ` ${line}`)
|
|
14295
|
-
];
|
|
14296
|
-
if (state.deviceName) lines.push(`${style(color, "2", "Device")} ${truncate(state.deviceName, Math.max(8, usable - 11))}`);
|
|
14297
|
-
if (state.showHelp) {
|
|
14298
|
-
lines.push("", style(color, "1", "Keys"));
|
|
14299
|
-
lines.push(" d toggle recent activity");
|
|
14300
|
-
lines.push(" ? hide this help");
|
|
14301
|
-
lines.push(" q disconnect and quit");
|
|
14302
|
-
} else if (state.showDetails) {
|
|
14303
|
-
lines.push("", style(color, "1", "Recent activity"));
|
|
14304
|
-
if (state.activities.length === 0) lines.push(style(color, "2", " No tool activity yet."));
|
|
14305
|
-
for (const activity of state.activities.slice(-5)) {
|
|
14306
|
-
const outcome = activity.ok === false ? style(color, "31", "\xD7") : style(color, "2", "\xB7");
|
|
14307
|
-
lines.push(`${outcome} ${truncate(activity.label, Math.max(8, usable - 16))} ${style(color, "2", activity.requestId.slice(0, 8))}`);
|
|
14308
|
-
}
|
|
14309
|
-
} else {
|
|
14310
|
-
const latest = state.activities.at(-1);
|
|
14311
|
-
lines.push("", latest ? `${style(color, "2", "Latest")} ${truncate(latest.label, Math.max(8, usable - 11))}` : style(color, "2", "No tool activity yet."));
|
|
14312
|
-
}
|
|
14313
|
-
lines.push("", style(color, "2", "d details ? help q disconnect"));
|
|
14314
|
-
return lines.join("\n");
|
|
14315
|
-
}
|
|
14316
|
-
async function runSessionHud(actions, input = process.stdin, output = process.stdout) {
|
|
14317
|
-
if (!input.isTTY || !output.isTTY) {
|
|
14318
|
-
throw new Error("glossa ui requires an interactive terminal. Use glossa start instead.");
|
|
14319
|
-
}
|
|
14320
|
-
emitKeypressEvents(input);
|
|
14321
|
-
const wasRaw = input.isRaw;
|
|
14322
|
-
const wasPaused = input.isPaused();
|
|
14323
|
-
const controller = new AbortController();
|
|
14324
|
-
let state = initialHudState(actions.workspace);
|
|
14325
|
-
let stopUi;
|
|
14326
|
-
const render = () => {
|
|
14327
|
-
const view = renderHud(state, output.columns ?? 80);
|
|
14328
|
-
output.write(`\x1B[H\x1B[2J${view}`);
|
|
14329
|
-
};
|
|
14330
|
-
const session = actions.run(controller.signal, (event) => {
|
|
14331
|
-
state = applyHudEvent(state, event);
|
|
14332
|
-
render();
|
|
14333
|
-
}).then(() => {
|
|
14334
|
-
if (!controller.signal.aborted) state = { ...state, connection: "disconnected" };
|
|
14335
|
-
render();
|
|
14336
|
-
}).catch((error46) => {
|
|
14337
|
-
state = {
|
|
14338
|
-
...state,
|
|
14339
|
-
connection: "error",
|
|
14340
|
-
message: error46 instanceof Error ? error46.message : String(error46)
|
|
14341
|
-
};
|
|
14342
|
-
render();
|
|
14343
|
-
throw error46;
|
|
14344
|
-
});
|
|
14345
|
-
input.setRawMode(true);
|
|
14346
|
-
input.resume();
|
|
14347
|
-
output.write("\x1B[?1049h\x1B[?25l");
|
|
14348
|
-
render();
|
|
14349
|
-
const stop = () => {
|
|
14350
|
-
controller.abort();
|
|
14351
|
-
stopUi?.();
|
|
14352
|
-
};
|
|
14353
|
-
process.once("SIGINT", stop);
|
|
14354
|
-
process.once("SIGTERM", stop);
|
|
14355
|
-
try {
|
|
14356
|
-
await new Promise((resolve) => {
|
|
14357
|
-
stopUi = resolve;
|
|
14358
|
-
const onKeypress = (value, key) => {
|
|
14359
|
-
if (key.ctrl && key.name === "c" || key.name === "q") return stop();
|
|
14360
|
-
if (key.name === "d") {
|
|
14361
|
-
state = { ...state, showDetails: !state.showDetails, showHelp: false };
|
|
14362
|
-
render();
|
|
14363
|
-
} else if (value === "?" || key.sequence === "?") {
|
|
14364
|
-
state = { ...state, showHelp: !state.showHelp };
|
|
14365
|
-
render();
|
|
14366
|
-
}
|
|
14367
|
-
};
|
|
14368
|
-
input.on("keypress", onKeypress);
|
|
14369
|
-
void session.catch(() => stopUi?.());
|
|
14370
|
-
stopUi = () => {
|
|
14371
|
-
input.removeListener("keypress", onKeypress);
|
|
14372
|
-
resolve();
|
|
14373
|
-
};
|
|
14374
|
-
});
|
|
14375
|
-
await session;
|
|
14376
|
-
} finally {
|
|
14377
|
-
process.removeListener("SIGINT", stop);
|
|
14378
|
-
process.removeListener("SIGTERM", stop);
|
|
14379
|
-
input.setRawMode(wasRaw);
|
|
14380
|
-
if (wasPaused) input.pause();
|
|
14381
|
-
output.write("\x1B[?25h\x1B[?1049l");
|
|
14382
|
-
}
|
|
14383
|
-
}
|
|
14384
|
-
|
|
14385
|
-
// src/first-run.ts
|
|
14386
|
-
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
14387
|
-
import path5 from "node:path";
|
|
14388
|
-
var CONNECT_HINT_FILE = "connect-hint-shown";
|
|
14389
|
-
var CONNECT_HINT_URL = "https://glossa.sh/docs/quickstart";
|
|
14390
|
-
function shouldShowConnectHint(relayOrigin) {
|
|
14391
|
-
return relayOrigin === DEFAULT_RELAY_ORIGIN;
|
|
14392
|
-
}
|
|
14393
|
-
function connectHintStore(directory = configDirectory()) {
|
|
14394
|
-
const file2 = path5.join(directory, CONNECT_HINT_FILE);
|
|
13847
|
+
content: utf8PrefixWithinBudget(safePrefix(prefix), prefixBudget) + utf8SuffixWithinBudget(safeSuffix(suffix), tailBudget),
|
|
13848
|
+
truncated: true
|
|
13849
|
+
};
|
|
13850
|
+
}
|
|
13851
|
+
function renderOutput(stdout, stderr, complete) {
|
|
13852
|
+
const half = Math.floor(MAX_COMMAND_OUTPUT_BYTES / 2);
|
|
13853
|
+
const stdoutAvailable = retainedBytes(stdout, complete);
|
|
13854
|
+
const stderrAvailable = retainedBytes(stderr, complete);
|
|
13855
|
+
let stdoutBudget = Math.min(stdoutAvailable, half);
|
|
13856
|
+
let stderrBudget = Math.min(stderrAvailable, half);
|
|
13857
|
+
let remaining = MAX_COMMAND_OUTPUT_BYTES - stdoutBudget - stderrBudget;
|
|
13858
|
+
const stderrExtra = Math.min(remaining, stderrAvailable - stderrBudget);
|
|
13859
|
+
stderrBudget += stderrExtra;
|
|
13860
|
+
remaining -= stderrExtra;
|
|
13861
|
+
stdoutBudget += Math.min(remaining, stdoutAvailable - stdoutBudget);
|
|
14395
13862
|
return {
|
|
14396
|
-
|
|
14397
|
-
|
|
14398
|
-
await readFile2(file2, "utf8");
|
|
14399
|
-
return true;
|
|
14400
|
-
} catch (error46) {
|
|
14401
|
-
if (error46.code === "ENOENT") return false;
|
|
14402
|
-
throw error46;
|
|
14403
|
-
}
|
|
14404
|
-
},
|
|
14405
|
-
async mark() {
|
|
14406
|
-
await mkdir2(directory, { recursive: true, mode: 448 });
|
|
14407
|
-
await writeFile2(file2, "", { encoding: "utf8", mode: 384 });
|
|
14408
|
-
}
|
|
13863
|
+
stdout: renderStream(stdout, stdoutBudget, complete),
|
|
13864
|
+
stderr: renderStream(stderr, stderrBudget, complete)
|
|
14409
13865
|
};
|
|
14410
13866
|
}
|
|
14411
|
-
async function announceConnectHint(store3, log) {
|
|
14412
|
-
if (await store3.exists()) return false;
|
|
14413
|
-
log(`Next: add Glossa in ChatGPT. Follow the quickstart at ${CONNECT_HINT_URL}.`);
|
|
14414
|
-
await store3.mark();
|
|
14415
|
-
return true;
|
|
14416
|
-
}
|
|
14417
|
-
|
|
14418
|
-
// src/worker/command-service.ts
|
|
14419
|
-
import { spawn as spawn2 } from "node:child_process";
|
|
14420
|
-
import { randomUUID } from "node:crypto";
|
|
14421
|
-
import { StringDecoder } from "node:string_decoder";
|
|
14422
|
-
import { setTimeout as delay2 } from "node:timers/promises";
|
|
14423
|
-
|
|
14424
|
-
// src/worker/errors.ts
|
|
14425
|
-
var WorkerError = class extends Error {
|
|
14426
|
-
constructor(code, message) {
|
|
14427
|
-
super(message);
|
|
14428
|
-
this.code = code;
|
|
14429
|
-
this.name = "WorkerError";
|
|
14430
|
-
}
|
|
14431
|
-
code;
|
|
14432
|
-
};
|
|
14433
|
-
|
|
14434
|
-
// src/worker/command-service.ts
|
|
14435
|
-
function capture(stream, chunk) {
|
|
14436
|
-
if (stream.bytes >= MAX_TEXT_BYTES) {
|
|
14437
|
-
stream.truncated = true;
|
|
14438
|
-
return;
|
|
14439
|
-
}
|
|
14440
|
-
const remaining = MAX_TEXT_BYTES - stream.bytes;
|
|
14441
|
-
const accepted = chunk.subarray(0, remaining);
|
|
14442
|
-
stream.chunks.push(accepted);
|
|
14443
|
-
stream.bytes += accepted.byteLength;
|
|
14444
|
-
if (accepted.byteLength < chunk.byteLength) stream.truncated = true;
|
|
14445
|
-
}
|
|
14446
|
-
function emptyCapture() {
|
|
14447
|
-
return { chunks: [], bytes: 0, truncated: false };
|
|
14448
|
-
}
|
|
14449
|
-
function decodeCapture(stream) {
|
|
14450
|
-
const content = Buffer.concat(stream.chunks);
|
|
14451
|
-
return stream.truncated ? new StringDecoder("utf8").write(content) : content.toString("utf8");
|
|
14452
|
-
}
|
|
14453
13867
|
function shellInvocation(command) {
|
|
14454
13868
|
if (process.platform === "win32") {
|
|
14455
13869
|
const file2 = process.env.GLOSSA_WINDOWS_SHELL ?? "powershell.exe";
|
|
@@ -14540,6 +13954,8 @@ var CommandService = class {
|
|
|
14540
13954
|
id,
|
|
14541
13955
|
child,
|
|
14542
13956
|
status: "running",
|
|
13957
|
+
sequence: 0,
|
|
13958
|
+
changeWaiters: /* @__PURE__ */ new Set(),
|
|
14543
13959
|
startedAt: Date.now(),
|
|
14544
13960
|
stdout: emptyCapture(),
|
|
14545
13961
|
stderr: emptyCapture(),
|
|
@@ -14554,15 +13970,20 @@ var CommandService = class {
|
|
|
14554
13970
|
record2.timeout.unref();
|
|
14555
13971
|
this.#commands.set(id, record2);
|
|
14556
13972
|
this.#activeCommandId = id;
|
|
14557
|
-
child.stdout.on("data", (chunk) =>
|
|
14558
|
-
|
|
13973
|
+
child.stdout.on("data", (chunk) => {
|
|
13974
|
+
if (capture(record2, record2.stdout, chunk)) markChanged(record2);
|
|
13975
|
+
});
|
|
13976
|
+
child.stderr.on("data", (chunk) => {
|
|
13977
|
+
if (capture(record2, record2.stderr, chunk)) markChanged(record2);
|
|
13978
|
+
});
|
|
14559
13979
|
child.once("error", (error46) => {
|
|
14560
13980
|
if (record2.status !== "running") return;
|
|
14561
13981
|
if (record2.timeout) clearTimeout(record2.timeout);
|
|
14562
13982
|
record2.status = "failed";
|
|
14563
13983
|
record2.finishedAt = Date.now();
|
|
14564
|
-
capture(record2.stderr, Buffer.from(error46.message, "utf8"));
|
|
13984
|
+
capture(record2, record2.stderr, Buffer.from(error46.message, "utf8"));
|
|
14565
13985
|
this.#activeCommandId = null;
|
|
13986
|
+
markChanged(record2);
|
|
14566
13987
|
record2.complete();
|
|
14567
13988
|
});
|
|
14568
13989
|
child.once("close", (exitCode, signal) => {
|
|
@@ -14573,6 +13994,7 @@ var CommandService = class {
|
|
|
14573
13994
|
record2.signal = signal;
|
|
14574
13995
|
record2.status = record2.requestedTerminal ?? (exitCode === 0 ? "succeeded" : "failed");
|
|
14575
13996
|
this.#activeCommandId = null;
|
|
13997
|
+
markChanged(record2);
|
|
14576
13998
|
record2.complete();
|
|
14577
13999
|
setTimeout(() => this.#commands.delete(id), 5 * 60 * 1e3).unref();
|
|
14578
14000
|
});
|
|
@@ -14601,21 +14023,31 @@ var CommandService = class {
|
|
|
14601
14023
|
}
|
|
14602
14024
|
return this.snapshot(record2);
|
|
14603
14025
|
}
|
|
14604
|
-
async get(commandId, waitMs = 0) {
|
|
14026
|
+
async get(commandId, waitMs = 0, afterSequence) {
|
|
14605
14027
|
const record2 = this.#commands.get(commandId);
|
|
14606
14028
|
if (!record2) throw new WorkerError("command_not_found", "The command was not found.");
|
|
14607
14029
|
if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > MAX_COMMAND_STATUS_WAIT_MS) {
|
|
14608
14030
|
throw new WorkerError("invalid_wait", "Status wait must be between 0 and 15 seconds.");
|
|
14609
14031
|
}
|
|
14032
|
+
if (afterSequence !== void 0 && (!Number.isInteger(afterSequence) || afterSequence < 0 || afterSequence > record2.sequence)) {
|
|
14033
|
+
throw new WorkerError(
|
|
14034
|
+
"invalid_sequence",
|
|
14035
|
+
"The command sequence is invalid for this command."
|
|
14036
|
+
);
|
|
14037
|
+
}
|
|
14610
14038
|
if (record2.status === "running" && waitMs > 0) {
|
|
14611
|
-
|
|
14612
|
-
|
|
14613
|
-
|
|
14614
|
-
|
|
14615
|
-
|
|
14616
|
-
|
|
14617
|
-
|
|
14618
|
-
|
|
14039
|
+
if (afterSequence === void 0) {
|
|
14040
|
+
const waitController = new AbortController();
|
|
14041
|
+
try {
|
|
14042
|
+
await Promise.race([
|
|
14043
|
+
record2.completion,
|
|
14044
|
+
delay2(waitMs, void 0, { signal: waitController.signal })
|
|
14045
|
+
]);
|
|
14046
|
+
} finally {
|
|
14047
|
+
waitController.abort();
|
|
14048
|
+
}
|
|
14049
|
+
} else {
|
|
14050
|
+
await waitForChange(record2, afterSequence, waitMs);
|
|
14619
14051
|
}
|
|
14620
14052
|
}
|
|
14621
14053
|
return this.snapshot(record2);
|
|
@@ -14638,31 +14070,225 @@ var CommandService = class {
|
|
|
14638
14070
|
await record2.completion;
|
|
14639
14071
|
}
|
|
14640
14072
|
snapshot(record2) {
|
|
14073
|
+
const output = renderOutput(
|
|
14074
|
+
record2.stdout,
|
|
14075
|
+
record2.stderr,
|
|
14076
|
+
record2.status !== "running"
|
|
14077
|
+
);
|
|
14641
14078
|
const base = {
|
|
14642
14079
|
commandId: record2.id,
|
|
14643
14080
|
status: record2.status,
|
|
14644
|
-
|
|
14081
|
+
sequence: record2.sequence,
|
|
14082
|
+
startedAt: new Date(record2.startedAt).toISOString(),
|
|
14083
|
+
stdout: output.stdout.content,
|
|
14084
|
+
stderr: output.stderr.content,
|
|
14085
|
+
stdoutTruncated: output.stdout.truncated,
|
|
14086
|
+
stderrTruncated: output.stderr.truncated
|
|
14645
14087
|
};
|
|
14646
14088
|
if (record2.finishedAt !== void 0) {
|
|
14647
14089
|
base.finishedAt = new Date(record2.finishedAt).toISOString();
|
|
14648
14090
|
base.exitCode = record2.exitCode ?? null;
|
|
14649
14091
|
base.signal = record2.signal ?? null;
|
|
14650
|
-
base.stdout = decodeCapture(record2.stdout);
|
|
14651
|
-
base.stderr = decodeCapture(record2.stderr);
|
|
14652
|
-
base.stdoutTruncated = record2.stdout.truncated;
|
|
14653
|
-
base.stderrTruncated = record2.stderr.truncated;
|
|
14654
14092
|
}
|
|
14655
14093
|
return base;
|
|
14656
14094
|
}
|
|
14657
14095
|
};
|
|
14658
14096
|
|
|
14659
14097
|
// src/worker/file-service.ts
|
|
14660
|
-
import { createHash
|
|
14661
|
-
import {
|
|
14662
|
-
|
|
14098
|
+
import { createHash, randomUUID as randomUUID2 } from "node:crypto";
|
|
14099
|
+
import {
|
|
14100
|
+
chmod as chmod2,
|
|
14101
|
+
lstat,
|
|
14102
|
+
opendir,
|
|
14103
|
+
open,
|
|
14104
|
+
readFile as readFile3,
|
|
14105
|
+
rename,
|
|
14106
|
+
rm as rm2,
|
|
14107
|
+
stat,
|
|
14108
|
+
writeFile as writeFile3
|
|
14109
|
+
} from "node:fs/promises";
|
|
14110
|
+
import path5 from "node:path";
|
|
14111
|
+
import { performance } from "node:perf_hooks";
|
|
14663
14112
|
import { StringDecoder as StringDecoder2 } from "node:string_decoder";
|
|
14664
14113
|
function sha256(content) {
|
|
14665
|
-
return
|
|
14114
|
+
return createHash("sha256").update(content).digest("hex");
|
|
14115
|
+
}
|
|
14116
|
+
var DEFAULT_LIST_FILES_LIMIT = 100;
|
|
14117
|
+
var DEFAULT_SEARCH_TEXT_RESULTS = 50;
|
|
14118
|
+
var DEFAULT_READ_RANGE_LINES = 200;
|
|
14119
|
+
var MAX_REPOSITORY_SCAN_ENTRIES = 2e4;
|
|
14120
|
+
var MAX_SEARCH_FILES = 5e3;
|
|
14121
|
+
var MAX_SEARCH_BYTES = 32 * 1024 * 1024;
|
|
14122
|
+
var SKIPPED_RECURSIVE_DIRECTORIES = /* @__PURE__ */ new Set([
|
|
14123
|
+
".git",
|
|
14124
|
+
".hg",
|
|
14125
|
+
".svn",
|
|
14126
|
+
"node_modules"
|
|
14127
|
+
]);
|
|
14128
|
+
function compareNames(left, right) {
|
|
14129
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
14130
|
+
}
|
|
14131
|
+
function relativeSortKey(root, target) {
|
|
14132
|
+
const relative = path5.relative(root, target);
|
|
14133
|
+
if (!relative) return ".";
|
|
14134
|
+
return process.platform === "win32" ? relative.replaceAll("\\", "/") : relative;
|
|
14135
|
+
}
|
|
14136
|
+
function displayPath(root, target) {
|
|
14137
|
+
const relative = relativeSortKey(root, target);
|
|
14138
|
+
if (process.platform === "win32") return relative;
|
|
14139
|
+
return relative.includes("\\") ? "./" + relative : relative;
|
|
14140
|
+
}
|
|
14141
|
+
function isUnavailableFileError(error46) {
|
|
14142
|
+
const code = error46?.code;
|
|
14143
|
+
return code === "ENOENT" || code === "EACCES" || code === "EPERM" || code === "ENOTDIR";
|
|
14144
|
+
}
|
|
14145
|
+
function isUnavailableDiscoveredPathError(error46) {
|
|
14146
|
+
return isUnavailableFileError(error46) || error46 instanceof WorkerError && error46.code === "path_not_found";
|
|
14147
|
+
}
|
|
14148
|
+
function isLinkedPathError(error46) {
|
|
14149
|
+
return error46 instanceof WorkerError && error46.code === "linked_path";
|
|
14150
|
+
}
|
|
14151
|
+
async function readBoundedFile(target, maximumBytes, expectedBytes) {
|
|
14152
|
+
const handle = await open(target, "r");
|
|
14153
|
+
try {
|
|
14154
|
+
const openedStat = await handle.stat();
|
|
14155
|
+
if (!openedStat.isFile()) {
|
|
14156
|
+
throw new WorkerError("not_file", "The requested path is not a file.");
|
|
14157
|
+
}
|
|
14158
|
+
if (openedStat.size !== expectedBytes) {
|
|
14159
|
+
throw new WorkerError("file_changed", "The file changed while it was being read.");
|
|
14160
|
+
}
|
|
14161
|
+
if (openedStat.size > maximumBytes) {
|
|
14162
|
+
throw new WorkerError("search_byte_limit", "The search byte limit was reached.");
|
|
14163
|
+
}
|
|
14164
|
+
const buffer = Buffer.allocUnsafe(openedStat.size);
|
|
14165
|
+
let offset = 0;
|
|
14166
|
+
while (offset < buffer.byteLength) {
|
|
14167
|
+
const { bytesRead } = await handle.read(
|
|
14168
|
+
buffer,
|
|
14169
|
+
offset,
|
|
14170
|
+
buffer.byteLength - offset,
|
|
14171
|
+
offset
|
|
14172
|
+
);
|
|
14173
|
+
if (bytesRead === 0) break;
|
|
14174
|
+
offset += bytesRead;
|
|
14175
|
+
}
|
|
14176
|
+
const completedStat = await handle.stat();
|
|
14177
|
+
if (completedStat.size !== openedStat.size || offset !== openedStat.size) {
|
|
14178
|
+
throw new WorkerError("file_changed", "The file changed while it was being read.");
|
|
14179
|
+
}
|
|
14180
|
+
return Buffer.from(buffer);
|
|
14181
|
+
} finally {
|
|
14182
|
+
await handle.close();
|
|
14183
|
+
}
|
|
14184
|
+
}
|
|
14185
|
+
function normalizedLines(content) {
|
|
14186
|
+
if (content.length === 0) return [];
|
|
14187
|
+
const normalized = content.replace(/\r\n?/g, "\n");
|
|
14188
|
+
const lines = normalized.split("\n");
|
|
14189
|
+
if (normalized.endsWith("\n")) lines.pop();
|
|
14190
|
+
return lines;
|
|
14191
|
+
}
|
|
14192
|
+
function escapeRegExp(value) {
|
|
14193
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14194
|
+
}
|
|
14195
|
+
function heapPush(heap, value) {
|
|
14196
|
+
heap.push(value);
|
|
14197
|
+
let index = heap.length - 1;
|
|
14198
|
+
while (index > 0) {
|
|
14199
|
+
const parent = Math.floor((index - 1) / 2);
|
|
14200
|
+
if (compareNames(heap[parent].sortKey, value.sortKey) <= 0) break;
|
|
14201
|
+
heap[index] = heap[parent];
|
|
14202
|
+
index = parent;
|
|
14203
|
+
}
|
|
14204
|
+
heap[index] = value;
|
|
14205
|
+
}
|
|
14206
|
+
function heapPop(heap) {
|
|
14207
|
+
const first = heap[0];
|
|
14208
|
+
const last = heap.pop();
|
|
14209
|
+
if (!first || !last || heap.length === 0) return first;
|
|
14210
|
+
let index = 0;
|
|
14211
|
+
while (true) {
|
|
14212
|
+
const left = index * 2 + 1;
|
|
14213
|
+
if (left >= heap.length) break;
|
|
14214
|
+
const right = left + 1;
|
|
14215
|
+
const child = right < heap.length && compareNames(heap[right].sortKey, heap[left].sortKey) < 0 ? right : left;
|
|
14216
|
+
if (compareNames(last.sortKey, heap[child].sortKey) <= 0) break;
|
|
14217
|
+
heap[index] = heap[child];
|
|
14218
|
+
index = child;
|
|
14219
|
+
}
|
|
14220
|
+
heap[index] = last;
|
|
14221
|
+
return first;
|
|
14222
|
+
}
|
|
14223
|
+
function scanTimeoutError() {
|
|
14224
|
+
return new WorkerError(
|
|
14225
|
+
"scan_timeout",
|
|
14226
|
+
"The structured repository operation exceeded its local deadline."
|
|
14227
|
+
);
|
|
14228
|
+
}
|
|
14229
|
+
async function defaultBeforeDeadline(operation, deadlineAt, onLateValue) {
|
|
14230
|
+
const settled = operation.then(
|
|
14231
|
+
(value) => ({ ok: true, value }),
|
|
14232
|
+
(error46) => ({ ok: false, error: error46 })
|
|
14233
|
+
);
|
|
14234
|
+
const remainingMs = deadlineAt - performance.now();
|
|
14235
|
+
const expired = /* @__PURE__ */ Symbol("structured_read_deadline");
|
|
14236
|
+
let winner;
|
|
14237
|
+
let timer;
|
|
14238
|
+
if (remainingMs <= 0) {
|
|
14239
|
+
winner = expired;
|
|
14240
|
+
} else {
|
|
14241
|
+
try {
|
|
14242
|
+
winner = await Promise.race([
|
|
14243
|
+
settled,
|
|
14244
|
+
new Promise((resolve) => {
|
|
14245
|
+
timer = setTimeout(() => resolve(expired), remainingMs);
|
|
14246
|
+
})
|
|
14247
|
+
]);
|
|
14248
|
+
} finally {
|
|
14249
|
+
if (timer) clearTimeout(timer);
|
|
14250
|
+
}
|
|
14251
|
+
}
|
|
14252
|
+
if (winner === expired) {
|
|
14253
|
+
const eventual = await settled;
|
|
14254
|
+
if (eventual.ok && onLateValue) {
|
|
14255
|
+
await onLateValue(eventual.value);
|
|
14256
|
+
}
|
|
14257
|
+
throw scanTimeoutError();
|
|
14258
|
+
}
|
|
14259
|
+
if (!winner.ok) throw winner.error;
|
|
14260
|
+
return winner.value;
|
|
14261
|
+
}
|
|
14262
|
+
async function closeDirectory(handle) {
|
|
14263
|
+
await handle.close().catch((error46) => {
|
|
14264
|
+
if (error46.code !== "ERR_DIR_CLOSED") throw error46;
|
|
14265
|
+
});
|
|
14266
|
+
}
|
|
14267
|
+
function boundedPositiveInteger(value, fallback, maximum, label) {
|
|
14268
|
+
const resolved = value ?? fallback;
|
|
14269
|
+
if (!Number.isInteger(resolved) || resolved < 1 || resolved > maximum) {
|
|
14270
|
+
throw new WorkerError(
|
|
14271
|
+
"invalid_limit",
|
|
14272
|
+
`${label} must be between 1 and ${maximum}.`
|
|
14273
|
+
);
|
|
14274
|
+
}
|
|
14275
|
+
return resolved;
|
|
14276
|
+
}
|
|
14277
|
+
function searchSnippet(line, matchIndex, matchLength) {
|
|
14278
|
+
if (line.length <= MAX_SEARCH_TEXT_SNIPPET_CHARS) {
|
|
14279
|
+
return { text: line, truncated: false };
|
|
14280
|
+
}
|
|
14281
|
+
const windowLength = MAX_SEARCH_TEXT_SNIPPET_CHARS - 6;
|
|
14282
|
+
let start = Math.max(0, matchIndex - 120);
|
|
14283
|
+
if (start + windowLength < matchIndex + matchLength) {
|
|
14284
|
+
start = matchIndex + matchLength - windowLength;
|
|
14285
|
+
}
|
|
14286
|
+
start = Math.min(start, line.length - windowLength);
|
|
14287
|
+
const end = start + windowLength;
|
|
14288
|
+
return {
|
|
14289
|
+
text: `${start > 0 ? "..." : ""}${line.slice(start, end)}${end < line.length ? "..." : ""}`,
|
|
14290
|
+
truncated: true
|
|
14291
|
+
};
|
|
14666
14292
|
}
|
|
14667
14293
|
function lineNumberAt(content, index) {
|
|
14668
14294
|
let line = 1;
|
|
@@ -14708,8 +14334,8 @@ function createUnifiedDiff(relativePath, original, updated, edits) {
|
|
|
14708
14334
|
hunks.push(candidate);
|
|
14709
14335
|
}
|
|
14710
14336
|
}
|
|
14711
|
-
const
|
|
14712
|
-
const lines = [`--- a/${
|
|
14337
|
+
const displayPath2 = process.platform === "win32" ? relativePath.replaceAll("\\", "/") : relativePath;
|
|
14338
|
+
const lines = [`--- a/${displayPath2}`, `+++ b/${displayPath2}`];
|
|
14713
14339
|
for (const hunk of hunks) {
|
|
14714
14340
|
const newStart = mappedIndex(hunk.oldStart, edits);
|
|
14715
14341
|
const newEnd = mappedIndex(hunk.oldEnd, edits);
|
|
@@ -14738,12 +14364,81 @@ function boundDiff(diff) {
|
|
|
14738
14364
|
return { diff: `${prefix}${marker}`, truncated: true };
|
|
14739
14365
|
}
|
|
14740
14366
|
var FileService = class {
|
|
14741
|
-
constructor(policy) {
|
|
14367
|
+
constructor(policy, dependencies = {}) {
|
|
14742
14368
|
this.policy = policy;
|
|
14369
|
+
this.#now = dependencies.now ?? (() => performance.now());
|
|
14370
|
+
this.#beforeDeadline = dependencies.beforeDeadline ?? defaultBeforeDeadline;
|
|
14371
|
+
this.#openDirectory = dependencies.openDirectory ?? ((directory) => opendir(directory));
|
|
14372
|
+
this.#readFileBytes = dependencies.readFileBytes ?? readBoundedFile;
|
|
14373
|
+
this.#lstatPath = dependencies.lstatPath ?? ((target) => lstat(target));
|
|
14374
|
+
this.#maxRepositoryScanEntries = dependencies.maxRepositoryScanEntries ?? MAX_REPOSITORY_SCAN_ENTRIES;
|
|
14375
|
+
this.#maxSearchBytes = dependencies.maxSearchBytes ?? MAX_SEARCH_BYTES;
|
|
14376
|
+
if (!Number.isInteger(this.#maxRepositoryScanEntries) || this.#maxRepositoryScanEntries < 1 || this.#maxRepositoryScanEntries > MAX_REPOSITORY_SCAN_ENTRIES) {
|
|
14377
|
+
throw new WorkerError(
|
|
14378
|
+
"invalid_limit",
|
|
14379
|
+
`Repository scan limit must be between 1 and ${MAX_REPOSITORY_SCAN_ENTRIES}.`
|
|
14380
|
+
);
|
|
14381
|
+
}
|
|
14382
|
+
if (!Number.isInteger(this.#maxSearchBytes) || this.#maxSearchBytes < 1 || this.#maxSearchBytes > MAX_SEARCH_BYTES) {
|
|
14383
|
+
throw new WorkerError(
|
|
14384
|
+
"invalid_limit",
|
|
14385
|
+
`Search byte limit must be between 1 and ${MAX_SEARCH_BYTES}.`
|
|
14386
|
+
);
|
|
14387
|
+
}
|
|
14743
14388
|
}
|
|
14744
14389
|
policy;
|
|
14745
|
-
|
|
14746
|
-
|
|
14390
|
+
#now;
|
|
14391
|
+
#beforeDeadline;
|
|
14392
|
+
#openDirectory;
|
|
14393
|
+
#readFileBytes;
|
|
14394
|
+
#lstatPath;
|
|
14395
|
+
#maxRepositoryScanEntries;
|
|
14396
|
+
#maxSearchBytes;
|
|
14397
|
+
#scanDeadline(timeoutMs = MAX_STRUCTURED_READ_TIMEOUT_MS) {
|
|
14398
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_STRUCTURED_READ_TIMEOUT_MS) {
|
|
14399
|
+
throw new WorkerError(
|
|
14400
|
+
"invalid_timeout",
|
|
14401
|
+
`Structured read timeout must be between 1 and ${MAX_STRUCTURED_READ_TIMEOUT_MS}.`
|
|
14402
|
+
);
|
|
14403
|
+
}
|
|
14404
|
+
return this.#now() + timeoutMs;
|
|
14405
|
+
}
|
|
14406
|
+
async #withinDeadline(operation, deadlineAt, onLateValue) {
|
|
14407
|
+
return await this.#beforeDeadline(operation, deadlineAt, onLateValue);
|
|
14408
|
+
}
|
|
14409
|
+
#assertBeforeDeadline(deadlineAt) {
|
|
14410
|
+
if (this.#now() >= deadlineAt) {
|
|
14411
|
+
throw new WorkerError(
|
|
14412
|
+
"scan_timeout",
|
|
14413
|
+
"The structured repository operation exceeded its local deadline."
|
|
14414
|
+
);
|
|
14415
|
+
}
|
|
14416
|
+
}
|
|
14417
|
+
async #readDirectoryBounded(directory, deadlineAt, remainingEntries) {
|
|
14418
|
+
const handle = await this.#withinDeadline(
|
|
14419
|
+
this.#openDirectory(directory),
|
|
14420
|
+
deadlineAt,
|
|
14421
|
+
closeDirectory
|
|
14422
|
+
);
|
|
14423
|
+
const children = [];
|
|
14424
|
+
try {
|
|
14425
|
+
while (true) {
|
|
14426
|
+
this.#assertBeforeDeadline(deadlineAt);
|
|
14427
|
+
const child = await this.#withinDeadline(handle.read(), deadlineAt);
|
|
14428
|
+
if (!child) break;
|
|
14429
|
+
if (children.length >= remainingEntries) {
|
|
14430
|
+
return { children: [], overflow: true };
|
|
14431
|
+
}
|
|
14432
|
+
children.push(child);
|
|
14433
|
+
}
|
|
14434
|
+
} finally {
|
|
14435
|
+
await closeDirectory(handle);
|
|
14436
|
+
}
|
|
14437
|
+
children.sort((left, right) => compareNames(left.name, right.name));
|
|
14438
|
+
return { children, overflow: false };
|
|
14439
|
+
}
|
|
14440
|
+
async #readResolvedText(target, maximumBytes = MAX_TEXT_BYTES) {
|
|
14441
|
+
const boundedMaximum = Math.min(maximumBytes, MAX_TEXT_BYTES);
|
|
14747
14442
|
const targetStat = await stat(target);
|
|
14748
14443
|
if (!targetStat.isFile()) {
|
|
14749
14444
|
throw new WorkerError("not_file", "The requested path is not a file.");
|
|
@@ -14751,15 +14446,471 @@ var FileService = class {
|
|
|
14751
14446
|
if (targetStat.size > MAX_TEXT_BYTES) {
|
|
14752
14447
|
throw new WorkerError("file_too_large", "The file exceeds the 1 MiB text limit.");
|
|
14753
14448
|
}
|
|
14754
|
-
|
|
14755
|
-
|
|
14756
|
-
|
|
14757
|
-
|
|
14758
|
-
|
|
14759
|
-
|
|
14449
|
+
if (targetStat.size > boundedMaximum) {
|
|
14450
|
+
throw new WorkerError("search_byte_limit", "The search byte limit was reached.");
|
|
14451
|
+
}
|
|
14452
|
+
const content = await this.#readFileBytes(
|
|
14453
|
+
target,
|
|
14454
|
+
boundedMaximum,
|
|
14455
|
+
targetStat.size
|
|
14456
|
+
);
|
|
14457
|
+
if (content.byteLength > MAX_TEXT_BYTES) {
|
|
14458
|
+
throw new WorkerError("file_too_large", "The file exceeds the 1 MiB text limit.");
|
|
14459
|
+
}
|
|
14460
|
+
if (content.byteLength > boundedMaximum) {
|
|
14461
|
+
throw new WorkerError("search_byte_limit", "The search byte limit was reached.");
|
|
14462
|
+
}
|
|
14463
|
+
if (content.byteLength > targetStat.size) {
|
|
14464
|
+
throw new WorkerError("file_changed", "The file changed while it was being read.");
|
|
14465
|
+
}
|
|
14466
|
+
let text;
|
|
14467
|
+
try {
|
|
14468
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
14469
|
+
} catch {
|
|
14470
|
+
throw new WorkerError("not_text", "The file is not valid UTF-8 text.");
|
|
14760
14471
|
}
|
|
14761
14472
|
return { content: text, sha256: sha256(content), bytes: content.byteLength };
|
|
14762
14473
|
}
|
|
14474
|
+
async readText(relativePath) {
|
|
14475
|
+
const target = await this.policy.resolveExisting(relativePath);
|
|
14476
|
+
return await this.#readResolvedText(target);
|
|
14477
|
+
}
|
|
14478
|
+
async listFiles(options = {}) {
|
|
14479
|
+
const deadlineAt = this.#scanDeadline(options.timeoutMs);
|
|
14480
|
+
const startPath = options.path ?? ".";
|
|
14481
|
+
const start = await this.#withinDeadline(
|
|
14482
|
+
this.policy.resolveExisting(startPath),
|
|
14483
|
+
deadlineAt
|
|
14484
|
+
);
|
|
14485
|
+
const startStat = await this.#withinDeadline(stat(start), deadlineAt);
|
|
14486
|
+
if (!startStat.isDirectory()) {
|
|
14487
|
+
throw new WorkerError("not_directory", "The requested path is not a directory.");
|
|
14488
|
+
}
|
|
14489
|
+
const limit = boundedPositiveInteger(
|
|
14490
|
+
options.limit,
|
|
14491
|
+
DEFAULT_LIST_FILES_LIMIT,
|
|
14492
|
+
MAX_LIST_FILES_RESULTS,
|
|
14493
|
+
"List limit"
|
|
14494
|
+
);
|
|
14495
|
+
const cursor = process.platform === "win32" ? options.cursor?.replaceAll("\\", "/") : options.cursor;
|
|
14496
|
+
const entries = [];
|
|
14497
|
+
const frontier = [];
|
|
14498
|
+
let scannedEntries = 0;
|
|
14499
|
+
let skippedLinks = 0;
|
|
14500
|
+
let hasMore = false;
|
|
14501
|
+
const scanLimitError = () => new WorkerError(
|
|
14502
|
+
"scan_limit",
|
|
14503
|
+
`The directory scan exceeds ${this.#maxRepositoryScanEntries} entries. Narrow the requested path.`
|
|
14504
|
+
);
|
|
14505
|
+
const enqueueChildren = async (directory) => {
|
|
14506
|
+
const batch = await this.#readDirectoryBounded(
|
|
14507
|
+
directory,
|
|
14508
|
+
deadlineAt,
|
|
14509
|
+
this.#maxRepositoryScanEntries - scannedEntries
|
|
14510
|
+
);
|
|
14511
|
+
if (batch.overflow) throw scanLimitError();
|
|
14512
|
+
scannedEntries += batch.children.length;
|
|
14513
|
+
for (const child of batch.children) {
|
|
14514
|
+
const target = path5.join(directory, child.name);
|
|
14515
|
+
heapPush(frontier, {
|
|
14516
|
+
target,
|
|
14517
|
+
sortKey: relativeSortKey(this.policy.root, target),
|
|
14518
|
+
path: displayPath(this.policy.root, target),
|
|
14519
|
+
name: child.name
|
|
14520
|
+
});
|
|
14521
|
+
}
|
|
14522
|
+
};
|
|
14523
|
+
const hasListableChild = async (directory) => {
|
|
14524
|
+
const handle = await this.#withinDeadline(
|
|
14525
|
+
this.#openDirectory(directory),
|
|
14526
|
+
deadlineAt,
|
|
14527
|
+
closeDirectory
|
|
14528
|
+
);
|
|
14529
|
+
try {
|
|
14530
|
+
while (true) {
|
|
14531
|
+
this.#assertBeforeDeadline(deadlineAt);
|
|
14532
|
+
const child = await this.#withinDeadline(handle.read(), deadlineAt);
|
|
14533
|
+
if (!child) return false;
|
|
14534
|
+
if (scannedEntries >= this.#maxRepositoryScanEntries) {
|
|
14535
|
+
throw scanLimitError();
|
|
14536
|
+
}
|
|
14537
|
+
scannedEntries += 1;
|
|
14538
|
+
const target = path5.join(directory, child.name);
|
|
14539
|
+
let childStat;
|
|
14540
|
+
try {
|
|
14541
|
+
childStat = await this.#withinDeadline(
|
|
14542
|
+
this.#lstatPath(target),
|
|
14543
|
+
deadlineAt
|
|
14544
|
+
);
|
|
14545
|
+
} catch (error46) {
|
|
14546
|
+
if (isUnavailableFileError(error46)) continue;
|
|
14547
|
+
throw error46;
|
|
14548
|
+
}
|
|
14549
|
+
if (childStat.isSymbolicLink()) {
|
|
14550
|
+
skippedLinks += 1;
|
|
14551
|
+
continue;
|
|
14552
|
+
}
|
|
14553
|
+
if (childStat.isFile() || childStat.isDirectory()) return true;
|
|
14554
|
+
}
|
|
14555
|
+
} finally {
|
|
14556
|
+
await closeDirectory(handle);
|
|
14557
|
+
}
|
|
14558
|
+
};
|
|
14559
|
+
await enqueueChildren(start);
|
|
14560
|
+
while (frontier.length > 0 && entries.length <= limit) {
|
|
14561
|
+
this.#assertBeforeDeadline(deadlineAt);
|
|
14562
|
+
const candidate = heapPop(frontier);
|
|
14563
|
+
let targetStat;
|
|
14564
|
+
try {
|
|
14565
|
+
targetStat = await this.#withinDeadline(
|
|
14566
|
+
this.#lstatPath(candidate.target),
|
|
14567
|
+
deadlineAt
|
|
14568
|
+
);
|
|
14569
|
+
} catch (error46) {
|
|
14570
|
+
if (isUnavailableFileError(error46)) continue;
|
|
14571
|
+
throw error46;
|
|
14572
|
+
}
|
|
14573
|
+
if (targetStat.isSymbolicLink()) {
|
|
14574
|
+
skippedLinks += 1;
|
|
14575
|
+
continue;
|
|
14576
|
+
}
|
|
14577
|
+
const type = targetStat.isDirectory() ? "directory" : targetStat.isFile() ? "file" : void 0;
|
|
14578
|
+
if (!type) continue;
|
|
14579
|
+
if (entries.length === limit && (!cursor || compareNames(candidate.sortKey, cursor) > 0)) {
|
|
14580
|
+
try {
|
|
14581
|
+
const safeCandidate = await this.#withinDeadline(
|
|
14582
|
+
this.policy.resolveDiscoveredExisting(candidate.target),
|
|
14583
|
+
deadlineAt
|
|
14584
|
+
);
|
|
14585
|
+
if (type === "directory" && options.recursive === true && !SKIPPED_RECURSIVE_DIRECTORIES.has(candidate.name.toLowerCase())) {
|
|
14586
|
+
const handle = await this.#withinDeadline(
|
|
14587
|
+
this.#openDirectory(safeCandidate),
|
|
14588
|
+
deadlineAt,
|
|
14589
|
+
closeDirectory
|
|
14590
|
+
);
|
|
14591
|
+
await closeDirectory(handle);
|
|
14592
|
+
}
|
|
14593
|
+
} catch (error46) {
|
|
14594
|
+
if (isUnavailableDiscoveredPathError(error46)) continue;
|
|
14595
|
+
if (isLinkedPathError(error46)) {
|
|
14596
|
+
skippedLinks += 1;
|
|
14597
|
+
continue;
|
|
14598
|
+
}
|
|
14599
|
+
throw error46;
|
|
14600
|
+
}
|
|
14601
|
+
hasMore = true;
|
|
14602
|
+
break;
|
|
14603
|
+
}
|
|
14604
|
+
const shouldReturn = !cursor || compareNames(candidate.sortKey, cursor) > 0;
|
|
14605
|
+
const shouldRecurse = type === "directory" && options.recursive === true && !SKIPPED_RECURSIVE_DIRECTORIES.has(candidate.name.toLowerCase());
|
|
14606
|
+
let safeDirectory;
|
|
14607
|
+
if (shouldRecurse) {
|
|
14608
|
+
try {
|
|
14609
|
+
safeDirectory = await this.#withinDeadline(
|
|
14610
|
+
this.policy.resolveDiscoveredExisting(candidate.target),
|
|
14611
|
+
deadlineAt
|
|
14612
|
+
);
|
|
14613
|
+
} catch (error46) {
|
|
14614
|
+
if (isUnavailableDiscoveredPathError(error46)) continue;
|
|
14615
|
+
if (isLinkedPathError(error46)) {
|
|
14616
|
+
skippedLinks += 1;
|
|
14617
|
+
continue;
|
|
14618
|
+
}
|
|
14619
|
+
throw error46;
|
|
14620
|
+
}
|
|
14621
|
+
}
|
|
14622
|
+
const fillsPage = shouldReturn && entries.length + 1 === limit;
|
|
14623
|
+
if (fillsPage && safeDirectory) {
|
|
14624
|
+
let hasChild;
|
|
14625
|
+
try {
|
|
14626
|
+
hasChild = await hasListableChild(safeDirectory);
|
|
14627
|
+
} catch (error46) {
|
|
14628
|
+
if (isUnavailableDiscoveredPathError(error46)) continue;
|
|
14629
|
+
throw error46;
|
|
14630
|
+
}
|
|
14631
|
+
entries.push({
|
|
14632
|
+
entry: { path: candidate.path, type },
|
|
14633
|
+
cursor: candidate.sortKey
|
|
14634
|
+
});
|
|
14635
|
+
if (hasChild) {
|
|
14636
|
+
hasMore = true;
|
|
14637
|
+
break;
|
|
14638
|
+
}
|
|
14639
|
+
continue;
|
|
14640
|
+
}
|
|
14641
|
+
if (safeDirectory) {
|
|
14642
|
+
try {
|
|
14643
|
+
await enqueueChildren(safeDirectory);
|
|
14644
|
+
} catch (error46) {
|
|
14645
|
+
if (isUnavailableDiscoveredPathError(error46)) continue;
|
|
14646
|
+
throw error46;
|
|
14647
|
+
}
|
|
14648
|
+
}
|
|
14649
|
+
if (shouldReturn) {
|
|
14650
|
+
entries.push({
|
|
14651
|
+
entry: {
|
|
14652
|
+
path: candidate.path,
|
|
14653
|
+
type,
|
|
14654
|
+
...type === "file" ? { bytes: targetStat.size } : {}
|
|
14655
|
+
},
|
|
14656
|
+
cursor: candidate.sortKey
|
|
14657
|
+
});
|
|
14658
|
+
}
|
|
14659
|
+
}
|
|
14660
|
+
return {
|
|
14661
|
+
entries: entries.map(({ entry }) => entry),
|
|
14662
|
+
truncated: hasMore,
|
|
14663
|
+
scannedEntries,
|
|
14664
|
+
skippedLinks,
|
|
14665
|
+
...hasMore && entries.length > 0 ? { nextCursor: entries.at(-1).cursor } : {}
|
|
14666
|
+
};
|
|
14667
|
+
}
|
|
14668
|
+
async searchText(options) {
|
|
14669
|
+
const deadlineAt = this.#scanDeadline(options.timeoutMs);
|
|
14670
|
+
if (options.query.length === 0 || options.query.length > 256 || /[\r\n\u0000]/.test(options.query)) {
|
|
14671
|
+
throw new WorkerError(
|
|
14672
|
+
"invalid_search",
|
|
14673
|
+
"Search text must be one non-empty line of at most 256 characters."
|
|
14674
|
+
);
|
|
14675
|
+
}
|
|
14676
|
+
const maxResults = boundedPositiveInteger(
|
|
14677
|
+
options.maxResults,
|
|
14678
|
+
DEFAULT_SEARCH_TEXT_RESULTS,
|
|
14679
|
+
MAX_SEARCH_TEXT_RESULTS,
|
|
14680
|
+
"Search result limit"
|
|
14681
|
+
);
|
|
14682
|
+
const matchLimit = maxResults + 1;
|
|
14683
|
+
const extensions = options.extensions?.map((extension) => extension.toLowerCase());
|
|
14684
|
+
const matcher = new RegExp(
|
|
14685
|
+
escapeRegExp(options.query),
|
|
14686
|
+
options.caseSensitive === true ? "u" : "iu"
|
|
14687
|
+
);
|
|
14688
|
+
const start = await this.#withinDeadline(
|
|
14689
|
+
this.policy.resolveExisting(options.path ?? "."),
|
|
14690
|
+
deadlineAt
|
|
14691
|
+
);
|
|
14692
|
+
const startStat = await this.#withinDeadline(stat(start), deadlineAt);
|
|
14693
|
+
const matches = [];
|
|
14694
|
+
let scannedEntries = 0;
|
|
14695
|
+
let scannedFiles = 0;
|
|
14696
|
+
let scannedBytes = 0;
|
|
14697
|
+
let skippedFiles = 0;
|
|
14698
|
+
let skippedLinks = 0;
|
|
14699
|
+
let scanTruncated = false;
|
|
14700
|
+
const searchFile = async (target) => {
|
|
14701
|
+
const relative = displayPath(this.policy.root, target);
|
|
14702
|
+
if (extensions && !extensions.some(
|
|
14703
|
+
(extension) => path5.basename(target).toLowerCase().endsWith(extension)
|
|
14704
|
+
)) {
|
|
14705
|
+
return false;
|
|
14706
|
+
}
|
|
14707
|
+
if (scannedFiles >= MAX_SEARCH_FILES) {
|
|
14708
|
+
scanTruncated = true;
|
|
14709
|
+
return true;
|
|
14710
|
+
}
|
|
14711
|
+
const remainingBytes = this.#maxSearchBytes - scannedBytes;
|
|
14712
|
+
if (remainingBytes <= 0) {
|
|
14713
|
+
scanTruncated = true;
|
|
14714
|
+
return true;
|
|
14715
|
+
}
|
|
14716
|
+
let result;
|
|
14717
|
+
try {
|
|
14718
|
+
const safeTarget = await this.#withinDeadline(
|
|
14719
|
+
this.policy.resolveDiscoveredExisting(target),
|
|
14720
|
+
deadlineAt
|
|
14721
|
+
);
|
|
14722
|
+
result = await this.#withinDeadline(
|
|
14723
|
+
this.#readResolvedText(safeTarget, remainingBytes),
|
|
14724
|
+
deadlineAt
|
|
14725
|
+
);
|
|
14726
|
+
} catch (error46) {
|
|
14727
|
+
if (error46 instanceof WorkerError && error46.code === "search_byte_limit") {
|
|
14728
|
+
scanTruncated = true;
|
|
14729
|
+
return true;
|
|
14730
|
+
}
|
|
14731
|
+
if (isLinkedPathError(error46)) {
|
|
14732
|
+
skippedLinks += 1;
|
|
14733
|
+
return false;
|
|
14734
|
+
}
|
|
14735
|
+
if (isUnavailableFileError(error46) || error46 instanceof WorkerError && [
|
|
14736
|
+
"not_text",
|
|
14737
|
+
"file_too_large",
|
|
14738
|
+
"file_changed",
|
|
14739
|
+
"path_not_found"
|
|
14740
|
+
].includes(
|
|
14741
|
+
error46.code
|
|
14742
|
+
)) {
|
|
14743
|
+
skippedFiles += 1;
|
|
14744
|
+
return false;
|
|
14745
|
+
}
|
|
14746
|
+
throw error46;
|
|
14747
|
+
}
|
|
14748
|
+
if (scannedBytes + result.bytes > this.#maxSearchBytes) {
|
|
14749
|
+
scanTruncated = true;
|
|
14750
|
+
return true;
|
|
14751
|
+
}
|
|
14752
|
+
scannedFiles += 1;
|
|
14753
|
+
scannedBytes += result.bytes;
|
|
14754
|
+
const lines = result.content.replace(/\r\n?/g, "\n").split("\n");
|
|
14755
|
+
for (const [index, line] of lines.entries()) {
|
|
14756
|
+
if (index % 256 === 0) this.#assertBeforeDeadline(deadlineAt);
|
|
14757
|
+
const match = matcher.exec(line);
|
|
14758
|
+
if (!match) continue;
|
|
14759
|
+
const matchIndex = match.index;
|
|
14760
|
+
const snippet = searchSnippet(line, matchIndex, match[0].length);
|
|
14761
|
+
matches.push({
|
|
14762
|
+
path: relative,
|
|
14763
|
+
line: index + 1,
|
|
14764
|
+
column: matchIndex + 1,
|
|
14765
|
+
text: snippet.text,
|
|
14766
|
+
lineTruncated: snippet.truncated
|
|
14767
|
+
});
|
|
14768
|
+
if (matches.length >= matchLimit) return true;
|
|
14769
|
+
}
|
|
14770
|
+
return false;
|
|
14771
|
+
};
|
|
14772
|
+
const visit = async (directory) => {
|
|
14773
|
+
let batch;
|
|
14774
|
+
try {
|
|
14775
|
+
batch = await this.#readDirectoryBounded(
|
|
14776
|
+
directory,
|
|
14777
|
+
deadlineAt,
|
|
14778
|
+
this.#maxRepositoryScanEntries - scannedEntries
|
|
14779
|
+
);
|
|
14780
|
+
} catch (error46) {
|
|
14781
|
+
if (isUnavailableFileError(error46)) {
|
|
14782
|
+
skippedFiles += 1;
|
|
14783
|
+
return false;
|
|
14784
|
+
}
|
|
14785
|
+
throw error46;
|
|
14786
|
+
}
|
|
14787
|
+
if (batch.overflow) {
|
|
14788
|
+
scanTruncated = true;
|
|
14789
|
+
scannedEntries = this.#maxRepositoryScanEntries;
|
|
14790
|
+
return true;
|
|
14791
|
+
}
|
|
14792
|
+
scannedEntries += batch.children.length;
|
|
14793
|
+
for (const child of batch.children) {
|
|
14794
|
+
this.#assertBeforeDeadline(deadlineAt);
|
|
14795
|
+
const target = path5.join(directory, child.name);
|
|
14796
|
+
let targetStat;
|
|
14797
|
+
try {
|
|
14798
|
+
targetStat = await this.#withinDeadline(this.#lstatPath(target), deadlineAt);
|
|
14799
|
+
} catch (error46) {
|
|
14800
|
+
if (isUnavailableFileError(error46)) {
|
|
14801
|
+
skippedFiles += 1;
|
|
14802
|
+
continue;
|
|
14803
|
+
}
|
|
14804
|
+
throw error46;
|
|
14805
|
+
}
|
|
14806
|
+
if (targetStat.isSymbolicLink()) {
|
|
14807
|
+
skippedLinks += 1;
|
|
14808
|
+
continue;
|
|
14809
|
+
}
|
|
14810
|
+
if (targetStat.isDirectory()) {
|
|
14811
|
+
if (SKIPPED_RECURSIVE_DIRECTORIES.has(child.name.toLowerCase())) continue;
|
|
14812
|
+
try {
|
|
14813
|
+
await this.#withinDeadline(
|
|
14814
|
+
this.policy.resolveDiscoveredExisting(target),
|
|
14815
|
+
deadlineAt
|
|
14816
|
+
);
|
|
14817
|
+
} catch (error46) {
|
|
14818
|
+
if (isLinkedPathError(error46)) {
|
|
14819
|
+
skippedLinks += 1;
|
|
14820
|
+
continue;
|
|
14821
|
+
}
|
|
14822
|
+
if (isUnavailableDiscoveredPathError(error46)) {
|
|
14823
|
+
skippedFiles += 1;
|
|
14824
|
+
continue;
|
|
14825
|
+
}
|
|
14826
|
+
throw error46;
|
|
14827
|
+
}
|
|
14828
|
+
if (await visit(target)) return true;
|
|
14829
|
+
} else if (targetStat.isFile() && await searchFile(target)) {
|
|
14830
|
+
return true;
|
|
14831
|
+
}
|
|
14832
|
+
}
|
|
14833
|
+
return false;
|
|
14834
|
+
};
|
|
14835
|
+
if (startStat.isFile()) {
|
|
14836
|
+
await searchFile(start);
|
|
14837
|
+
} else if (startStat.isDirectory()) {
|
|
14838
|
+
await visit(start);
|
|
14839
|
+
} else {
|
|
14840
|
+
throw new WorkerError("not_file", "The requested path is not a file or directory.");
|
|
14841
|
+
}
|
|
14842
|
+
return {
|
|
14843
|
+
matches: matches.slice(0, maxResults),
|
|
14844
|
+
truncated: scanTruncated || matches.length > maxResults,
|
|
14845
|
+
scannedFiles,
|
|
14846
|
+
scannedBytes,
|
|
14847
|
+
skippedFiles,
|
|
14848
|
+
skippedLinks
|
|
14849
|
+
};
|
|
14850
|
+
}
|
|
14851
|
+
async readTextRange(relativePath, startLine = 1, lineCount = DEFAULT_READ_RANGE_LINES, timeoutMs = MAX_STRUCTURED_READ_TIMEOUT_MS) {
|
|
14852
|
+
const deadlineAt = this.#scanDeadline(timeoutMs);
|
|
14853
|
+
if (!Number.isInteger(startLine) || startLine < 1) {
|
|
14854
|
+
throw new WorkerError("invalid_range", "Start line must be a positive integer.");
|
|
14855
|
+
}
|
|
14856
|
+
lineCount = boundedPositiveInteger(
|
|
14857
|
+
lineCount,
|
|
14858
|
+
DEFAULT_READ_RANGE_LINES,
|
|
14859
|
+
MAX_READ_FILE_RANGE_LINES,
|
|
14860
|
+
"Line count"
|
|
14861
|
+
);
|
|
14862
|
+
const file2 = await this.#withinDeadline(
|
|
14863
|
+
this.readText(relativePath),
|
|
14864
|
+
deadlineAt
|
|
14865
|
+
);
|
|
14866
|
+
const lines = normalizedLines(file2.content);
|
|
14867
|
+
if (lines.length === 0) {
|
|
14868
|
+
if (startLine !== 1) {
|
|
14869
|
+
throw new WorkerError("line_out_of_range", "The requested line is outside the file.");
|
|
14870
|
+
}
|
|
14871
|
+
return {
|
|
14872
|
+
content: "",
|
|
14873
|
+
startLine: 1,
|
|
14874
|
+
endLine: 0,
|
|
14875
|
+
totalLines: 0,
|
|
14876
|
+
sha256: file2.sha256,
|
|
14877
|
+
bytes: file2.bytes,
|
|
14878
|
+
contentBytes: 0
|
|
14879
|
+
};
|
|
14880
|
+
}
|
|
14881
|
+
if (startLine > lines.length) {
|
|
14882
|
+
throw new WorkerError("line_out_of_range", "The requested line is outside the file.");
|
|
14883
|
+
}
|
|
14884
|
+
const selected = [];
|
|
14885
|
+
let contentBytes = 0;
|
|
14886
|
+
const requestedEnd = Math.min(lines.length, startLine - 1 + lineCount);
|
|
14887
|
+
for (let index = startLine - 1; index < requestedEnd; index += 1) {
|
|
14888
|
+
const line = lines[index];
|
|
14889
|
+
const addedBytes = Buffer.byteLength(line, "utf8") + (selected.length === 0 ? 0 : 1);
|
|
14890
|
+
if (contentBytes + addedBytes > MAX_READ_FILE_RANGE_BYTES) {
|
|
14891
|
+
if (selected.length === 0) {
|
|
14892
|
+
throw new WorkerError(
|
|
14893
|
+
"line_too_large",
|
|
14894
|
+
"The first requested line exceeds the 64 KiB range limit. Use read_file instead."
|
|
14895
|
+
);
|
|
14896
|
+
}
|
|
14897
|
+
break;
|
|
14898
|
+
}
|
|
14899
|
+
selected.push(line);
|
|
14900
|
+
contentBytes += addedBytes;
|
|
14901
|
+
}
|
|
14902
|
+
const endLine = startLine + selected.length - 1;
|
|
14903
|
+
return {
|
|
14904
|
+
content: selected.join("\n"),
|
|
14905
|
+
startLine,
|
|
14906
|
+
endLine,
|
|
14907
|
+
totalLines: lines.length,
|
|
14908
|
+
sha256: file2.sha256,
|
|
14909
|
+
bytes: file2.bytes,
|
|
14910
|
+
contentBytes,
|
|
14911
|
+
...endLine < lines.length ? { nextLine: endLine + 1 } : {}
|
|
14912
|
+
};
|
|
14913
|
+
}
|
|
14763
14914
|
async editText(relativePath, edits, expectedSha256) {
|
|
14764
14915
|
const original = await this.readText(relativePath);
|
|
14765
14916
|
if (expectedSha256 && original.sha256 !== expectedSha256) {
|
|
@@ -14827,7 +14978,7 @@ var FileService = class {
|
|
|
14827
14978
|
throw new WorkerError("stale_revision", "The file revision has changed.");
|
|
14828
14979
|
}
|
|
14829
14980
|
}
|
|
14830
|
-
const temporary =
|
|
14981
|
+
const temporary = path5.join(path5.dirname(target), `.glossa-${randomUUID2()}.tmp`);
|
|
14831
14982
|
try {
|
|
14832
14983
|
await writeFile3(temporary, bytes, { flag: "wx", mode: 384 });
|
|
14833
14984
|
target = await this.policy.resolveWritableFile(relativePath);
|
|
@@ -14849,29 +15000,37 @@ var FileService = class {
|
|
|
14849
15000
|
// src/worker/path-policy.ts
|
|
14850
15001
|
import { lstat as lstat2, realpath, stat as stat2 } from "node:fs/promises";
|
|
14851
15002
|
import os3 from "node:os";
|
|
14852
|
-
import
|
|
15003
|
+
import path6 from "node:path";
|
|
14853
15004
|
function samePath(left, right) {
|
|
14854
15005
|
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
14855
15006
|
}
|
|
15007
|
+
function accountHomeDirectory() {
|
|
15008
|
+
try {
|
|
15009
|
+
return os3.userInfo().homedir;
|
|
15010
|
+
} catch {
|
|
15011
|
+
return os3.homedir();
|
|
15012
|
+
}
|
|
15013
|
+
}
|
|
14856
15014
|
function isWithin(root, candidate) {
|
|
14857
|
-
const relative =
|
|
14858
|
-
return relative === "" || !relative.startsWith(`..${
|
|
15015
|
+
const relative = path6.relative(root, candidate);
|
|
15016
|
+
return relative === "" || !relative.startsWith(`..${path6.sep}`) && relative !== ".." && !path6.isAbsolute(relative);
|
|
14859
15017
|
}
|
|
14860
15018
|
function validateRelativePath(value) {
|
|
14861
15019
|
if (value.includes("\0")) {
|
|
14862
15020
|
throw new WorkerError("invalid_path", "Paths cannot contain null bytes.");
|
|
14863
15021
|
}
|
|
14864
|
-
|
|
15022
|
+
const explicitNativePosixPath = process.platform !== "win32" && value.startsWith("./");
|
|
15023
|
+
if (path6.isAbsolute(value) || path6.posix.isAbsolute(value) || !explicitNativePosixPath && path6.win32.isAbsolute(value)) {
|
|
14865
15024
|
throw new WorkerError("absolute_path", "Absolute paths are not allowed.");
|
|
14866
15025
|
}
|
|
14867
|
-
const segments = value.split(/[\\/]+/);
|
|
15026
|
+
const segments = explicitNativePosixPath ? value.split(/\/+/).filter(Boolean) : value.split(/[\\/]+/);
|
|
14868
15027
|
if (segments.includes("..")) {
|
|
14869
15028
|
throw new WorkerError("path_traversal", "Parent path traversal is not allowed.");
|
|
14870
15029
|
}
|
|
14871
15030
|
return value === "" ? "." : value;
|
|
14872
15031
|
}
|
|
14873
|
-
async function canonicalizeRoot(candidate
|
|
14874
|
-
const root = await realpath(
|
|
15032
|
+
async function canonicalizeRoot(candidate) {
|
|
15033
|
+
const root = await realpath(path6.resolve(candidate)).catch((error46) => {
|
|
14875
15034
|
if (error46.code === "ENOENT") {
|
|
14876
15035
|
throw new WorkerError("root_not_found", "The workspace directory does not exist.");
|
|
14877
15036
|
}
|
|
@@ -14881,16 +15040,19 @@ async function canonicalizeRoot(candidate, allowBroadRoot = false) {
|
|
|
14881
15040
|
if (!rootStat.isDirectory()) {
|
|
14882
15041
|
throw new WorkerError("root_not_directory", "The exposed root must be a directory.");
|
|
14883
15042
|
}
|
|
14884
|
-
|
|
14885
|
-
|
|
14886
|
-
|
|
14887
|
-
|
|
14888
|
-
|
|
14889
|
-
|
|
14890
|
-
|
|
14891
|
-
|
|
14892
|
-
|
|
14893
|
-
|
|
15043
|
+
const filesystemRoot = path6.parse(root).root;
|
|
15044
|
+
const homes = await Promise.all(
|
|
15045
|
+
[os3.homedir(), accountHomeDirectory()].map(
|
|
15046
|
+
async (home) => await realpath(home).catch(() => path6.resolve(home))
|
|
15047
|
+
)
|
|
15048
|
+
);
|
|
15049
|
+
const isHomeDirectory = homes.some((home) => samePath(root, home));
|
|
15050
|
+
if (samePath(root, filesystemRoot) || isHomeDirectory) {
|
|
15051
|
+
const kind = isHomeDirectory ? "your home directory" : "a filesystem root";
|
|
15052
|
+
throw new WorkerError(
|
|
15053
|
+
"broad_root_refused",
|
|
15054
|
+
`The selected root is ${kind}, which Glossa will not expose. Choose a project directory instead.`
|
|
15055
|
+
);
|
|
14894
15056
|
}
|
|
14895
15057
|
return root;
|
|
14896
15058
|
}
|
|
@@ -14899,8 +15061,8 @@ var PathPolicy = class _PathPolicy {
|
|
|
14899
15061
|
this.root = root;
|
|
14900
15062
|
}
|
|
14901
15063
|
root;
|
|
14902
|
-
static async create(candidate
|
|
14903
|
-
return new _PathPolicy(await canonicalizeRoot(candidate
|
|
15064
|
+
static async create(candidate) {
|
|
15065
|
+
return new _PathPolicy(await canonicalizeRoot(candidate));
|
|
14904
15066
|
}
|
|
14905
15067
|
async resolveExisting(relativePath) {
|
|
14906
15068
|
const lexical = this.resolveLexical(relativePath);
|
|
@@ -14916,9 +15078,26 @@ var PathPolicy = class _PathPolicy {
|
|
|
14916
15078
|
}
|
|
14917
15079
|
return canonical;
|
|
14918
15080
|
}
|
|
15081
|
+
async resolveDiscoveredExisting(candidate) {
|
|
15082
|
+
const lexical = path6.resolve(candidate);
|
|
15083
|
+
if (!isWithin(this.root, lexical)) {
|
|
15084
|
+
throw new WorkerError("path_escape", "The requested path escapes the exposed root.");
|
|
15085
|
+
}
|
|
15086
|
+
await this.rejectLinkedComponents(lexical);
|
|
15087
|
+
const canonical = await realpath(lexical).catch((error46) => {
|
|
15088
|
+
if (error46.code === "ENOENT") {
|
|
15089
|
+
throw new WorkerError("path_not_found", "The requested path does not exist.");
|
|
15090
|
+
}
|
|
15091
|
+
throw error46;
|
|
15092
|
+
});
|
|
15093
|
+
if (!isWithin(this.root, canonical)) {
|
|
15094
|
+
throw new WorkerError("path_escape", "The requested path escapes the exposed root.");
|
|
15095
|
+
}
|
|
15096
|
+
return canonical;
|
|
15097
|
+
}
|
|
14919
15098
|
async resolveWritableFile(relativePath) {
|
|
14920
15099
|
const lexical = this.resolveLexical(relativePath);
|
|
14921
|
-
const parent =
|
|
15100
|
+
const parent = path6.dirname(lexical);
|
|
14922
15101
|
await this.rejectLinkedComponents(parent);
|
|
14923
15102
|
const canonicalParent = await realpath(parent).catch((error46) => {
|
|
14924
15103
|
if (error46.code === "ENOENT") {
|
|
@@ -14944,11 +15123,11 @@ var PathPolicy = class _PathPolicy {
|
|
|
14944
15123
|
if (error46 instanceof WorkerError) throw error46;
|
|
14945
15124
|
if (error46.code !== "ENOENT") throw error46;
|
|
14946
15125
|
}
|
|
14947
|
-
return
|
|
15126
|
+
return path6.join(canonicalParent, path6.basename(lexical));
|
|
14948
15127
|
}
|
|
14949
15128
|
resolveLexical(relativePath) {
|
|
14950
15129
|
const validated = validateRelativePath(relativePath);
|
|
14951
|
-
const candidate =
|
|
15130
|
+
const candidate = path6.resolve(this.root, validated);
|
|
14952
15131
|
if (!isWithin(this.root, candidate)) {
|
|
14953
15132
|
throw new WorkerError("path_escape", "The requested path escapes the exposed root.");
|
|
14954
15133
|
}
|
|
@@ -14958,11 +15137,11 @@ var PathPolicy = class _PathPolicy {
|
|
|
14958
15137
|
if (!isWithin(this.root, candidate)) {
|
|
14959
15138
|
throw new WorkerError("path_escape", "The requested path escapes the exposed root.");
|
|
14960
15139
|
}
|
|
14961
|
-
const relative =
|
|
15140
|
+
const relative = path6.relative(this.root, candidate);
|
|
14962
15141
|
if (!relative) return;
|
|
14963
15142
|
let current = this.root;
|
|
14964
|
-
for (const segment of relative.split(
|
|
14965
|
-
current =
|
|
15143
|
+
for (const segment of relative.split(path6.sep)) {
|
|
15144
|
+
current = path6.join(current, segment);
|
|
14966
15145
|
try {
|
|
14967
15146
|
const currentStat = await lstat2(current);
|
|
14968
15147
|
if (currentStat.isSymbolicLink()) {
|
|
@@ -14982,16 +15161,16 @@ var PathPolicy = class _PathPolicy {
|
|
|
14982
15161
|
|
|
14983
15162
|
// src/worker/local-worker.ts
|
|
14984
15163
|
var LocalWorker = class _LocalWorker {
|
|
14985
|
-
constructor(policy, files,
|
|
15164
|
+
constructor(policy, files, commands) {
|
|
14986
15165
|
this.policy = policy;
|
|
14987
15166
|
this.files = files;
|
|
14988
|
-
this.commands =
|
|
15167
|
+
this.commands = commands;
|
|
14989
15168
|
}
|
|
14990
15169
|
policy;
|
|
14991
15170
|
files;
|
|
14992
15171
|
commands;
|
|
14993
|
-
static async create(root
|
|
14994
|
-
const policy = await PathPolicy.create(root
|
|
15172
|
+
static async create(root) {
|
|
15173
|
+
const policy = await PathPolicy.create(root);
|
|
14995
15174
|
return new _LocalWorker(
|
|
14996
15175
|
policy,
|
|
14997
15176
|
new FileService(policy),
|
|
@@ -15005,6 +15184,33 @@ var LocalWorker = class _LocalWorker {
|
|
|
15005
15184
|
case "read_file":
|
|
15006
15185
|
value = await this.files.readText(job.path);
|
|
15007
15186
|
break;
|
|
15187
|
+
case "list_files":
|
|
15188
|
+
value = await this.files.listFiles({
|
|
15189
|
+
...job.path ? { path: job.path } : {},
|
|
15190
|
+
...job.recursive === void 0 ? {} : { recursive: job.recursive },
|
|
15191
|
+
...job.cursor ? { cursor: job.cursor } : {},
|
|
15192
|
+
...job.limit === void 0 ? {} : { limit: job.limit },
|
|
15193
|
+
timeoutMs: job.timeoutMs
|
|
15194
|
+
});
|
|
15195
|
+
break;
|
|
15196
|
+
case "search_text":
|
|
15197
|
+
value = await this.files.searchText({
|
|
15198
|
+
query: job.query,
|
|
15199
|
+
...job.path ? { path: job.path } : {},
|
|
15200
|
+
...job.caseSensitive === void 0 ? {} : { caseSensitive: job.caseSensitive },
|
|
15201
|
+
...job.maxResults === void 0 ? {} : { maxResults: job.maxResults },
|
|
15202
|
+
...job.extensions ? { extensions: job.extensions } : {},
|
|
15203
|
+
timeoutMs: job.timeoutMs
|
|
15204
|
+
});
|
|
15205
|
+
break;
|
|
15206
|
+
case "read_file_range":
|
|
15207
|
+
value = await this.files.readTextRange(
|
|
15208
|
+
job.path,
|
|
15209
|
+
job.startLine,
|
|
15210
|
+
job.lineCount,
|
|
15211
|
+
job.timeoutMs
|
|
15212
|
+
);
|
|
15213
|
+
break;
|
|
15008
15214
|
case "write_file":
|
|
15009
15215
|
value = await this.files.writeText(
|
|
15010
15216
|
job.path,
|
|
@@ -15029,7 +15235,11 @@ var LocalWorker = class _LocalWorker {
|
|
|
15029
15235
|
});
|
|
15030
15236
|
break;
|
|
15031
15237
|
case "get_command":
|
|
15032
|
-
value = await this.commands.get(
|
|
15238
|
+
value = await this.commands.get(
|
|
15239
|
+
job.commandId,
|
|
15240
|
+
job.waitMs,
|
|
15241
|
+
job.afterSequence
|
|
15242
|
+
);
|
|
15033
15243
|
break;
|
|
15034
15244
|
case "cancel_command":
|
|
15035
15245
|
value = await this.commands.cancel(job.commandId);
|
|
@@ -15056,6 +15266,9 @@ var WORKER_REQUEST_TIMEOUT_MS = 19e3;
|
|
|
15056
15266
|
var DEFAULT_RECONNECT_BASE_MS = 500;
|
|
15057
15267
|
var DEFAULT_RECONNECT_MAX_MS = 1e4;
|
|
15058
15268
|
var DEFAULT_HEARTBEAT_MS = 15e3;
|
|
15269
|
+
var CONCURRENT_WORKER_POLL_MS = 1e3;
|
|
15270
|
+
var MAX_CONCURRENT_JOBS = 5;
|
|
15271
|
+
var WORKER_TOKEN_PATTERN = /^glw_[A-Za-z0-9_-]{43}$/;
|
|
15059
15272
|
var DeviceRejectedError = class extends Error {
|
|
15060
15273
|
constructor() {
|
|
15061
15274
|
super("The relay rejected the device credential.");
|
|
@@ -15070,6 +15283,53 @@ var RelayResponseError = class extends Error {
|
|
|
15070
15283
|
}
|
|
15071
15284
|
status;
|
|
15072
15285
|
};
|
|
15286
|
+
function optionalWorkerToken(value) {
|
|
15287
|
+
if (value === void 0) return void 0;
|
|
15288
|
+
if (typeof value !== "string" || !WORKER_TOKEN_PATTERN.test(value)) {
|
|
15289
|
+
throw new Error("The relay returned an invalid worker credential.");
|
|
15290
|
+
}
|
|
15291
|
+
return value;
|
|
15292
|
+
}
|
|
15293
|
+
function supportsCapability(value, capability) {
|
|
15294
|
+
if (typeof value !== "object" || value === null) return false;
|
|
15295
|
+
if (!("capabilities" in value)) return false;
|
|
15296
|
+
const capabilities = value.capabilities;
|
|
15297
|
+
if (typeof capabilities !== "object" || capabilities === null) return false;
|
|
15298
|
+
return capabilities[capability] === true;
|
|
15299
|
+
}
|
|
15300
|
+
function jobLane(job) {
|
|
15301
|
+
switch (job.type) {
|
|
15302
|
+
case "get_command":
|
|
15303
|
+
return "status";
|
|
15304
|
+
case "cancel_command":
|
|
15305
|
+
return "cancel";
|
|
15306
|
+
case "read_file":
|
|
15307
|
+
case "list_files":
|
|
15308
|
+
case "search_text":
|
|
15309
|
+
case "read_file_range":
|
|
15310
|
+
return "read";
|
|
15311
|
+
case "write_file":
|
|
15312
|
+
case "edit_file":
|
|
15313
|
+
case "run_command":
|
|
15314
|
+
return "mutation";
|
|
15315
|
+
}
|
|
15316
|
+
}
|
|
15317
|
+
function acceptedJobTypes(counts, total, structuredReads) {
|
|
15318
|
+
if (total >= MAX_CONCURRENT_JOBS) return [];
|
|
15319
|
+
const accepted = [];
|
|
15320
|
+
if (counts.status < 1) accepted.push("get_command");
|
|
15321
|
+
if (counts.cancel < 1) accepted.push("cancel_command");
|
|
15322
|
+
if (counts.read < 2) {
|
|
15323
|
+
accepted.push("read_file");
|
|
15324
|
+
if (structuredReads) {
|
|
15325
|
+
accepted.push("list_files", "search_text", "read_file_range");
|
|
15326
|
+
}
|
|
15327
|
+
}
|
|
15328
|
+
if (counts.mutation < 1) {
|
|
15329
|
+
accepted.push("write_file", "edit_file", "run_command");
|
|
15330
|
+
}
|
|
15331
|
+
return accepted;
|
|
15332
|
+
}
|
|
15073
15333
|
function defaultSleep(milliseconds, signal) {
|
|
15074
15334
|
if (signal.aborted) return Promise.reject(signal.reason);
|
|
15075
15335
|
return new Promise((resolve, reject) => {
|
|
@@ -15092,6 +15352,7 @@ function reconnectDelayMs(failureCount, random, baseMs = DEFAULT_RECONNECT_BASE_
|
|
|
15092
15352
|
var RemoteWorker = class {
|
|
15093
15353
|
#origin;
|
|
15094
15354
|
#deviceToken;
|
|
15355
|
+
#workspaceLabel;
|
|
15095
15356
|
#worker;
|
|
15096
15357
|
#signal;
|
|
15097
15358
|
#fetcher;
|
|
@@ -15105,6 +15366,7 @@ var RemoteWorker = class {
|
|
|
15105
15366
|
constructor(options) {
|
|
15106
15367
|
this.#origin = new URL(options.origin);
|
|
15107
15368
|
this.#deviceToken = options.deviceToken;
|
|
15369
|
+
this.#workspaceLabel = options.workspaceLabel;
|
|
15108
15370
|
this.#worker = options.worker;
|
|
15109
15371
|
this.#signal = options.signal;
|
|
15110
15372
|
this.#fetcher = options.fetcher ?? fetch;
|
|
@@ -15119,15 +15381,18 @@ var RemoteWorker = class {
|
|
|
15119
15381
|
async run() {
|
|
15120
15382
|
let failures = 0;
|
|
15121
15383
|
let connectedBefore = false;
|
|
15384
|
+
let registeredSession;
|
|
15122
15385
|
this.#onStatus({ state: "connecting" });
|
|
15123
15386
|
try {
|
|
15124
15387
|
while (!this.#signal.aborted) {
|
|
15125
15388
|
try {
|
|
15126
15389
|
const session = await this.#register();
|
|
15390
|
+
registeredSession = session;
|
|
15127
15391
|
this.#onStatus({
|
|
15128
15392
|
state: "connected",
|
|
15129
15393
|
reconnected: connectedBefore,
|
|
15130
|
-
legacyRelay: session.legacyRelay
|
|
15394
|
+
legacyRelay: session.legacyRelay,
|
|
15395
|
+
workspaceLabelAccepted: session.workspaceLabelAccepted
|
|
15131
15396
|
});
|
|
15132
15397
|
connectedBefore = true;
|
|
15133
15398
|
failures = 0;
|
|
@@ -15156,350 +15421,1230 @@ var RemoteWorker = class {
|
|
|
15156
15421
|
}
|
|
15157
15422
|
}
|
|
15158
15423
|
} finally {
|
|
15159
|
-
await this.#unregister();
|
|
15424
|
+
await this.#unregister(registeredSession);
|
|
15160
15425
|
this.#onStatus({ state: "disconnected" });
|
|
15161
15426
|
}
|
|
15162
15427
|
}
|
|
15163
15428
|
async #register() {
|
|
15164
|
-
|
|
15165
|
-
|
|
15166
|
-
|
|
15167
|
-
|
|
15168
|
-
|
|
15169
|
-
|
|
15170
|
-
|
|
15429
|
+
const structuredBody = {
|
|
15430
|
+
workerId: this.#workerId,
|
|
15431
|
+
capabilities: {
|
|
15432
|
+
commandProgress: true,
|
|
15433
|
+
concurrentJobs: true,
|
|
15434
|
+
structuredReads: true
|
|
15435
|
+
}
|
|
15436
|
+
};
|
|
15437
|
+
const concurrentBody = {
|
|
15438
|
+
workerId: this.#workerId,
|
|
15439
|
+
capabilities: { commandProgress: true, concurrentJobs: true }
|
|
15440
|
+
};
|
|
15441
|
+
const attempts = [
|
|
15442
|
+
...this.#workspaceLabel ? [{
|
|
15443
|
+
body: {
|
|
15444
|
+
...structuredBody,
|
|
15445
|
+
workspaceLabel: this.#workspaceLabel
|
|
15446
|
+
},
|
|
15447
|
+
legacyRelay: false
|
|
15448
|
+
}] : [],
|
|
15449
|
+
{
|
|
15450
|
+
body: structuredBody,
|
|
15451
|
+
legacyRelay: false
|
|
15452
|
+
},
|
|
15453
|
+
...this.#workspaceLabel ? [{
|
|
15454
|
+
body: {
|
|
15455
|
+
...concurrentBody,
|
|
15456
|
+
workspaceLabel: this.#workspaceLabel
|
|
15457
|
+
},
|
|
15458
|
+
legacyRelay: false
|
|
15459
|
+
}] : [],
|
|
15460
|
+
{
|
|
15461
|
+
body: concurrentBody,
|
|
15462
|
+
legacyRelay: false
|
|
15463
|
+
},
|
|
15464
|
+
{
|
|
15465
|
+
body: {
|
|
15466
|
+
workerId: this.#workerId,
|
|
15467
|
+
capabilities: { commandProgress: true }
|
|
15468
|
+
},
|
|
15469
|
+
legacyRelay: false
|
|
15470
|
+
},
|
|
15471
|
+
{ body: { workerId: this.#workerId }, legacyRelay: false },
|
|
15472
|
+
{ body: {}, legacyRelay: true }
|
|
15473
|
+
];
|
|
15474
|
+
for (const [index, attempt] of attempts.entries()) {
|
|
15475
|
+
let response;
|
|
15476
|
+
try {
|
|
15477
|
+
response = await this.#post("/device/register", attempt.body);
|
|
15478
|
+
} catch (error46) {
|
|
15479
|
+
if (error46 instanceof RelayResponseError && error46.status === 400 && index < attempts.length - 1) {
|
|
15480
|
+
continue;
|
|
15481
|
+
}
|
|
15171
15482
|
throw error46;
|
|
15172
15483
|
}
|
|
15173
|
-
|
|
15174
|
-
|
|
15175
|
-
if (typeof legacyValue !== "object" || legacyValue === null || !("generation" in legacyValue) || typeof legacyValue.generation !== "string") {
|
|
15484
|
+
const value = await response.json();
|
|
15485
|
+
if (typeof value !== "object" || value === null || !("generation" in value) || typeof value.generation !== "string") {
|
|
15176
15486
|
throw new Error("The relay returned an invalid registration response.");
|
|
15177
15487
|
}
|
|
15178
|
-
|
|
15179
|
-
|
|
15180
|
-
|
|
15181
|
-
|
|
15182
|
-
|
|
15488
|
+
if (!attempt.legacyRelay && (!("workerId" in value) || value.workerId !== this.#workerId)) {
|
|
15489
|
+
throw new Error("The relay returned an invalid registration response.");
|
|
15490
|
+
}
|
|
15491
|
+
const workerToken = "workerToken" in value ? optionalWorkerToken(value.workerToken) : void 0;
|
|
15492
|
+
return {
|
|
15493
|
+
generation: value.generation,
|
|
15494
|
+
legacyRelay: attempt.legacyRelay,
|
|
15495
|
+
concurrentJobs: !attempt.legacyRelay && supportsCapability(value, "concurrentJobs"),
|
|
15496
|
+
structuredReads: !attempt.legacyRelay && supportsCapability(value, "structuredReads"),
|
|
15497
|
+
workspaceLabelAccepted: this.#workspaceLabel === void 0 || "workspaceLabel" in value && value.workspaceLabel === this.#workspaceLabel,
|
|
15498
|
+
...workerToken ? { workerToken } : {}
|
|
15499
|
+
};
|
|
15183
15500
|
}
|
|
15184
|
-
|
|
15501
|
+
throw new Error("The relay rejected every supported registration shape.");
|
|
15185
15502
|
}
|
|
15186
15503
|
async #pollGeneration(session) {
|
|
15504
|
+
if (session.legacyRelay || !session.concurrentJobs) {
|
|
15505
|
+
await this.#pollSequentially(session);
|
|
15506
|
+
return;
|
|
15507
|
+
}
|
|
15508
|
+
await this.#pollConcurrently(session);
|
|
15509
|
+
}
|
|
15510
|
+
async #pollSequentially(session) {
|
|
15187
15511
|
while (!this.#signal.aborted) {
|
|
15188
|
-
const
|
|
15189
|
-
|
|
15190
|
-
|
|
15191
|
-
|
|
15192
|
-
|
|
15193
|
-
|
|
15194
|
-
|
|
15195
|
-
|
|
15196
|
-
|
|
15197
|
-
|
|
15198
|
-
|
|
15199
|
-
|
|
15200
|
-
|
|
15201
|
-
|
|
15202
|
-
|
|
15203
|
-
|
|
15204
|
-
|
|
15512
|
+
const job = await this.#pollForJob(session);
|
|
15513
|
+
if (!job) continue;
|
|
15514
|
+
await this.#handleAndPost(session, job, true);
|
|
15515
|
+
}
|
|
15516
|
+
}
|
|
15517
|
+
async #pollConcurrently(session) {
|
|
15518
|
+
const counts = {
|
|
15519
|
+
status: 0,
|
|
15520
|
+
cancel: 0,
|
|
15521
|
+
read: 0,
|
|
15522
|
+
mutation: 0
|
|
15523
|
+
};
|
|
15524
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
15525
|
+
let failure;
|
|
15526
|
+
let heartbeat;
|
|
15527
|
+
const stopHeartbeat = () => {
|
|
15528
|
+
if (!heartbeat) return;
|
|
15529
|
+
clearInterval(heartbeat);
|
|
15530
|
+
heartbeat = void 0;
|
|
15531
|
+
};
|
|
15532
|
+
const ensureHeartbeat = () => {
|
|
15533
|
+
if (heartbeat) return;
|
|
15534
|
+
heartbeat = setInterval(() => {
|
|
15535
|
+
void this.#post(
|
|
15536
|
+
"/device/heartbeat",
|
|
15537
|
+
{ workerId: this.#workerId, generation: session.generation },
|
|
15538
|
+
session.workerToken
|
|
15539
|
+
).catch(() => {
|
|
15205
15540
|
});
|
|
15206
15541
|
}, this.#heartbeatMs);
|
|
15207
|
-
heartbeat
|
|
15208
|
-
|
|
15209
|
-
|
|
15210
|
-
|
|
15211
|
-
|
|
15212
|
-
|
|
15542
|
+
heartbeat.unref();
|
|
15543
|
+
};
|
|
15544
|
+
const dispatch = (job) => {
|
|
15545
|
+
const lane = jobLane(job);
|
|
15546
|
+
counts[lane] += 1;
|
|
15547
|
+
ensureHeartbeat();
|
|
15548
|
+
let task;
|
|
15549
|
+
task = this.#handleAndPost(session, job, false).catch((error46) => {
|
|
15550
|
+
failure ??= error46;
|
|
15551
|
+
}).finally(() => {
|
|
15552
|
+
counts[lane] -= 1;
|
|
15553
|
+
inFlight.delete(task);
|
|
15554
|
+
if (inFlight.size === 0) stopHeartbeat();
|
|
15555
|
+
});
|
|
15556
|
+
inFlight.add(task);
|
|
15557
|
+
};
|
|
15558
|
+
try {
|
|
15559
|
+
while (!this.#signal.aborted) {
|
|
15560
|
+
if (failure !== void 0) throw failure;
|
|
15561
|
+
const acceptedTypes = acceptedJobTypes(
|
|
15562
|
+
counts,
|
|
15563
|
+
inFlight.size,
|
|
15564
|
+
session.structuredReads
|
|
15565
|
+
);
|
|
15566
|
+
if (acceptedTypes.length === 0) {
|
|
15567
|
+
await Promise.race(inFlight);
|
|
15568
|
+
continue;
|
|
15569
|
+
}
|
|
15570
|
+
const job = await this.#pollForJob(
|
|
15571
|
+
session,
|
|
15572
|
+
acceptedTypes,
|
|
15573
|
+
inFlight.size > 0 ? CONCURRENT_WORKER_POLL_MS : void 0
|
|
15574
|
+
);
|
|
15575
|
+
if (!job) continue;
|
|
15576
|
+
if (!acceptedTypes.includes(job.type)) {
|
|
15577
|
+
throw new Error("The relay delivered a job outside worker capacity.");
|
|
15578
|
+
}
|
|
15579
|
+
dispatch(job);
|
|
15213
15580
|
}
|
|
15581
|
+
} finally {
|
|
15582
|
+
stopHeartbeat();
|
|
15583
|
+
await Promise.allSettled(inFlight);
|
|
15584
|
+
}
|
|
15585
|
+
if (failure !== void 0) throw failure;
|
|
15586
|
+
}
|
|
15587
|
+
async #pollForJob(session, acceptedTypes, waitMs) {
|
|
15588
|
+
const response = await this.#post(
|
|
15589
|
+
"/device/poll",
|
|
15590
|
+
session.legacyRelay ? { generation: session.generation } : {
|
|
15591
|
+
workerId: this.#workerId,
|
|
15592
|
+
generation: session.generation,
|
|
15593
|
+
...acceptedTypes ? { acceptedTypes } : {},
|
|
15594
|
+
...waitMs === void 0 ? {} : { waitMs }
|
|
15595
|
+
},
|
|
15596
|
+
session.workerToken
|
|
15597
|
+
);
|
|
15598
|
+
if (response.status === 204) return null;
|
|
15599
|
+
const value = await response.json();
|
|
15600
|
+
const parsed = workerJobSchema.safeParse(
|
|
15601
|
+
typeof value === "object" && value !== null && "job" in value ? value.job : void 0
|
|
15602
|
+
);
|
|
15603
|
+
if (!parsed.success) {
|
|
15604
|
+
throw new Error("The relay returned an invalid worker job.");
|
|
15605
|
+
}
|
|
15606
|
+
return parsed.data;
|
|
15607
|
+
}
|
|
15608
|
+
async #handleAndPost(session, job, heartbeatWhileRunning) {
|
|
15609
|
+
const heartbeat = heartbeatWhileRunning && !session.legacyRelay ? setInterval(() => {
|
|
15610
|
+
void this.#post(
|
|
15611
|
+
"/device/heartbeat",
|
|
15612
|
+
{ workerId: this.#workerId, generation: session.generation },
|
|
15613
|
+
session.workerToken
|
|
15614
|
+
).catch(() => {
|
|
15615
|
+
});
|
|
15616
|
+
}, this.#heartbeatMs) : void 0;
|
|
15617
|
+
heartbeat?.unref();
|
|
15618
|
+
let result;
|
|
15619
|
+
try {
|
|
15620
|
+
result = await this.#worker.handle(job);
|
|
15621
|
+
} finally {
|
|
15622
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
15623
|
+
}
|
|
15624
|
+
try {
|
|
15214
15625
|
await this.#post(
|
|
15215
15626
|
"/device/result",
|
|
15216
|
-
session.legacyRelay ? result : { workerId: this.#workerId, result }
|
|
15627
|
+
session.legacyRelay ? result : { workerId: this.#workerId, result },
|
|
15628
|
+
session.workerToken
|
|
15217
15629
|
);
|
|
15630
|
+
} catch (error46) {
|
|
15631
|
+
if (error46 instanceof RelayResponseError && error46.status === 410) return;
|
|
15632
|
+
throw error46;
|
|
15218
15633
|
}
|
|
15219
15634
|
}
|
|
15220
|
-
async #unregister() {
|
|
15221
|
-
|
|
15222
|
-
|
|
15223
|
-
|
|
15224
|
-
|
|
15225
|
-
|
|
15226
|
-
|
|
15227
|
-
|
|
15228
|
-
|
|
15229
|
-
|
|
15230
|
-
|
|
15231
|
-
|
|
15635
|
+
async #unregister(session) {
|
|
15636
|
+
const unregister = async (authorization) => {
|
|
15637
|
+
try {
|
|
15638
|
+
const response = await this.#fetcher(
|
|
15639
|
+
new URL("/device/unregister", this.#origin),
|
|
15640
|
+
{
|
|
15641
|
+
method: "POST",
|
|
15642
|
+
headers: {
|
|
15643
|
+
authorization,
|
|
15644
|
+
"content-type": "application/json"
|
|
15645
|
+
},
|
|
15646
|
+
body: JSON.stringify({ workerId: this.#workerId }),
|
|
15647
|
+
signal: AbortSignal.timeout(3e3)
|
|
15648
|
+
}
|
|
15649
|
+
);
|
|
15650
|
+
if (response.ok) return "accepted";
|
|
15651
|
+
return [400, 401, 404, 409].includes(response.status) ? "rejected" : "unreachable";
|
|
15652
|
+
} catch {
|
|
15653
|
+
return "unreachable";
|
|
15654
|
+
}
|
|
15655
|
+
};
|
|
15656
|
+
if (!session?.workerToken) {
|
|
15657
|
+
await unregister(`Device ${this.#deviceToken}`);
|
|
15658
|
+
return;
|
|
15659
|
+
}
|
|
15660
|
+
const result = await unregister(`Worker ${session.workerToken}`);
|
|
15661
|
+
if (result === "rejected") {
|
|
15662
|
+
await unregister(`Device ${this.#deviceToken}`);
|
|
15232
15663
|
}
|
|
15233
15664
|
}
|
|
15234
|
-
async #post(path8, body) {
|
|
15665
|
+
async #post(path8, body, workerToken) {
|
|
15235
15666
|
const timeout = AbortSignal.timeout(WORKER_REQUEST_TIMEOUT_MS);
|
|
15236
15667
|
const signal = AbortSignal.any([this.#signal, timeout]);
|
|
15237
15668
|
const response = await this.#fetcher(new URL(path8, this.#origin), {
|
|
15238
15669
|
method: "POST",
|
|
15239
15670
|
headers: {
|
|
15240
|
-
authorization: `Device ${this.#deviceToken}`,
|
|
15671
|
+
authorization: workerToken ? `Worker ${workerToken}` : `Device ${this.#deviceToken}`,
|
|
15241
15672
|
"content-type": "application/json"
|
|
15242
15673
|
},
|
|
15243
15674
|
body: JSON.stringify(body),
|
|
15244
15675
|
signal
|
|
15245
15676
|
});
|
|
15246
|
-
if (response.status === 401) throw new DeviceRejectedError();
|
|
15677
|
+
if (response.status === 401 && !workerToken) throw new DeviceRejectedError();
|
|
15247
15678
|
if (!response.ok) throw new RelayResponseError(response.status);
|
|
15248
15679
|
return response;
|
|
15249
15680
|
}
|
|
15250
15681
|
};
|
|
15251
15682
|
|
|
15252
15683
|
// src/worker/managed-session.ts
|
|
15253
|
-
|
|
15254
|
-
|
|
15255
|
-
|
|
15256
|
-
|
|
15257
|
-
|
|
15258
|
-
|
|
15259
|
-
|
|
15260
|
-
|
|
15261
|
-
|
|
15684
|
+
function report(options, event, message) {
|
|
15685
|
+
options.onEvent?.(event);
|
|
15686
|
+
if (!options.quiet) console.error(message);
|
|
15687
|
+
}
|
|
15688
|
+
function activityResultLabel(job, result) {
|
|
15689
|
+
if (!result.ok) return `${job.type} failed`;
|
|
15690
|
+
if (job.type === "run_command" && result.value && typeof result.value === "object" && "status" in result.value && result.value.status === "running") {
|
|
15691
|
+
return "run_command started";
|
|
15692
|
+
}
|
|
15693
|
+
return `${job.type} completed`;
|
|
15694
|
+
}
|
|
15695
|
+
function visibleWorker(worker, options) {
|
|
15696
|
+
return {
|
|
15697
|
+
async handle(job) {
|
|
15698
|
+
options.onEvent?.({ type: "activity", phase: "started", job });
|
|
15699
|
+
try {
|
|
15700
|
+
const result = await worker.handle(job);
|
|
15701
|
+
report(
|
|
15702
|
+
options,
|
|
15703
|
+
{ type: "activity", phase: "returned", job, ok: result.ok },
|
|
15704
|
+
`${activityResultLabel(job, result)} (${job.requestId}).`
|
|
15705
|
+
);
|
|
15706
|
+
return result;
|
|
15707
|
+
} catch (error46) {
|
|
15708
|
+
report(
|
|
15709
|
+
options,
|
|
15710
|
+
{ type: "activity", phase: "returned", job, ok: false },
|
|
15711
|
+
`${job.type} failed (${job.requestId}).`
|
|
15712
|
+
);
|
|
15713
|
+
throw error46;
|
|
15714
|
+
}
|
|
15715
|
+
}
|
|
15716
|
+
};
|
|
15717
|
+
}
|
|
15718
|
+
async function deviceForSession(endpoints, dependencies = {}, signal) {
|
|
15719
|
+
const loadDevice = dependencies.loadDeviceCredential ?? loadDeviceCredential;
|
|
15720
|
+
const loadLogin = dependencies.loadCredentials ?? loadCredentials;
|
|
15721
|
+
const validate = dependencies.validCredentials ?? validCredentials;
|
|
15722
|
+
const subjectFor = dependencies.accessTokenSubject ?? accessTokenSubject;
|
|
15723
|
+
const removeDevice = dependencies.deleteDeviceCredential ?? deleteDeviceCredential;
|
|
15724
|
+
const enroll = dependencies.enrollDevice ?? enrollDevice;
|
|
15725
|
+
const saveDevice = dependencies.saveDeviceCredential ?? saveDeviceCredential;
|
|
15726
|
+
const ownsDevice = dependencies.accountOwnsDevice ?? accountOwnsDevice;
|
|
15727
|
+
const name = dependencies.defaultDeviceName ?? defaultDeviceName;
|
|
15728
|
+
const baseFetch = dependencies.fetch ?? fetch;
|
|
15729
|
+
const fetchRequest = signal ? async (input, init) => await baseFetch(input, { ...init, signal }) : baseFetch;
|
|
15730
|
+
signal?.throwIfAborted();
|
|
15731
|
+
const stored = await loadDevice();
|
|
15732
|
+
let credentials = dependencies.credentials;
|
|
15733
|
+
const currentCredentials = async () => {
|
|
15734
|
+
if (credentials) return credentials;
|
|
15735
|
+
const loaded = await loadLogin();
|
|
15736
|
+
if (!loaded) throw new Error("Not signed in. Run Glossa again to sign in.");
|
|
15737
|
+
credentials = await validate(loaded.credentials, { fetch: fetchRequest });
|
|
15738
|
+
return credentials;
|
|
15739
|
+
};
|
|
15740
|
+
if (stored?.relayOrigin === endpoints.relayOrigin) {
|
|
15741
|
+
const current2 = await currentCredentials();
|
|
15742
|
+
const accountSubject = subjectFor(current2);
|
|
15743
|
+
if (stored.accountSubject === accountSubject) return stored;
|
|
15744
|
+
if (stored.accountSubject === void 0 && await ownsDevice(endpoints, current2, stored.deviceId, fetchRequest)) {
|
|
15745
|
+
const migrated = { ...stored, accountSubject };
|
|
15746
|
+
await saveDevice(migrated);
|
|
15747
|
+
return migrated;
|
|
15748
|
+
}
|
|
15749
|
+
await removeDevice();
|
|
15750
|
+
}
|
|
15751
|
+
signal?.throwIfAborted();
|
|
15752
|
+
const current = await currentCredentials();
|
|
15753
|
+
const enrolled = await enroll(
|
|
15754
|
+
endpoints,
|
|
15755
|
+
current,
|
|
15756
|
+
name(),
|
|
15757
|
+
fetchRequest
|
|
15758
|
+
);
|
|
15759
|
+
const bound = {
|
|
15760
|
+
...enrolled,
|
|
15761
|
+
accountSubject: subjectFor(current)
|
|
15762
|
+
};
|
|
15763
|
+
await saveDevice(bound);
|
|
15764
|
+
return bound;
|
|
15765
|
+
}
|
|
15766
|
+
async function reenrollRejectedDevice(endpoints, dependencies = {}, signal) {
|
|
15767
|
+
const remove = dependencies.deleteDeviceCredential ?? deleteDeviceCredential;
|
|
15768
|
+
await remove();
|
|
15769
|
+
return await deviceForSession(endpoints, dependencies, signal);
|
|
15770
|
+
}
|
|
15771
|
+
function retryMessage(retryInMs) {
|
|
15772
|
+
const seconds = Math.max(1, Math.ceil(retryInMs / 1e3));
|
|
15773
|
+
return `Retrying in ${seconds} ${seconds === 1 ? "second" : "seconds"}.`;
|
|
15774
|
+
}
|
|
15775
|
+
function workspaceLabelNotice(status, requestedLabel) {
|
|
15776
|
+
if (status.state !== "connected" || !requestedLabel || status.workspaceLabelAccepted !== false) {
|
|
15777
|
+
return void 0;
|
|
15778
|
+
}
|
|
15779
|
+
return "The relay needs an update before workspace labels are available. This workspace is online without the requested label.";
|
|
15780
|
+
}
|
|
15781
|
+
var legacyRelayNotice = "The relay needs an update before this computer can expose several workspaces at once.";
|
|
15782
|
+
function combinedCompatibilityNotice(labelNotice, includeLegacyRelayNotice) {
|
|
15783
|
+
const messages = [
|
|
15784
|
+
labelNotice,
|
|
15785
|
+
includeLegacyRelayNotice ? legacyRelayNotice : void 0
|
|
15786
|
+
].filter((message) => Boolean(message));
|
|
15787
|
+
return messages.length > 0 ? messages.join(" ") : void 0;
|
|
15788
|
+
}
|
|
15789
|
+
function statusMessage(status, connectedBefore) {
|
|
15790
|
+
if (status.state === "connecting") return "Connecting to Glossa...";
|
|
15791
|
+
if (status.state === "connected") {
|
|
15792
|
+
return status.reconnected ? "Reconnected to Glossa." : "Connected to Glossa. ChatGPT can now use this workspace.";
|
|
15793
|
+
}
|
|
15794
|
+
if (status.state === "retrying") {
|
|
15795
|
+
const prefix = connectedBefore ? "Connection lost" : "Could not connect";
|
|
15796
|
+
return `${prefix}: ${status.error.message} ${retryMessage(status.retryInMs)}`;
|
|
15797
|
+
}
|
|
15798
|
+
return "Disconnected from Glossa.";
|
|
15799
|
+
}
|
|
15800
|
+
async function connectRemoteWorker(endpoints, device, worker, options, signal, onConnected) {
|
|
15801
|
+
let connectionState;
|
|
15802
|
+
let connectedBefore = false;
|
|
15803
|
+
let labelNoticeShown = false;
|
|
15804
|
+
let legacyNoticeShown = false;
|
|
15805
|
+
let connectHintTask;
|
|
15806
|
+
const remoteWorker = new RemoteWorker({
|
|
15807
|
+
origin: endpoints.workerOrigin,
|
|
15808
|
+
deviceToken: device.token,
|
|
15809
|
+
...options.workspaceLabel ? { workspaceLabel: options.workspaceLabel } : {},
|
|
15810
|
+
worker: visibleWorker(worker, options),
|
|
15811
|
+
signal,
|
|
15812
|
+
onStatus(status) {
|
|
15813
|
+
if (status.state === "connected") {
|
|
15814
|
+
connectedBefore = true;
|
|
15815
|
+
onConnected();
|
|
15816
|
+
}
|
|
15817
|
+
if (status.state !== "retrying" || connectionState !== "retrying") {
|
|
15818
|
+
report(options, { type: "status", status }, statusMessage(status, connectedBefore));
|
|
15819
|
+
} else {
|
|
15820
|
+
options.onEvent?.({ type: "status", status });
|
|
15821
|
+
}
|
|
15822
|
+
const labelNotice = labelNoticeShown ? void 0 : workspaceLabelNotice(status, options.workspaceLabel);
|
|
15823
|
+
const includeLegacyNotice = status.state === "connected" && status.legacyRelay && !legacyNoticeShown;
|
|
15824
|
+
const compatibilityNotice = combinedCompatibilityNotice(
|
|
15825
|
+
labelNotice,
|
|
15826
|
+
includeLegacyNotice
|
|
15827
|
+
);
|
|
15828
|
+
if (labelNotice) labelNoticeShown = true;
|
|
15829
|
+
if (includeLegacyNotice) legacyNoticeShown = true;
|
|
15830
|
+
if (compatibilityNotice) {
|
|
15831
|
+
report(
|
|
15832
|
+
options,
|
|
15833
|
+
{ type: "notice", message: compatibilityNotice },
|
|
15834
|
+
compatibilityNotice
|
|
15835
|
+
);
|
|
15836
|
+
}
|
|
15837
|
+
if (status.state === "connected" && !status.reconnected && !compatibilityNotice && shouldShowConnectHint(endpoints.relayOrigin) && !connectHintTask) {
|
|
15838
|
+
connectHintTask = announceConnectHint(
|
|
15839
|
+
connectHintStore(),
|
|
15840
|
+
(message) => {
|
|
15841
|
+
report(
|
|
15842
|
+
options,
|
|
15843
|
+
{ type: "notice", message, persistAfterExit: true },
|
|
15844
|
+
message
|
|
15845
|
+
);
|
|
15846
|
+
}
|
|
15847
|
+
).then(() => void 0).catch(() => void 0);
|
|
15848
|
+
}
|
|
15849
|
+
connectionState = status.state;
|
|
15850
|
+
}
|
|
15851
|
+
});
|
|
15852
|
+
try {
|
|
15853
|
+
await remoteWorker.run();
|
|
15854
|
+
} finally {
|
|
15855
|
+
await connectHintTask;
|
|
15856
|
+
}
|
|
15857
|
+
}
|
|
15858
|
+
function shouldRecoverRejectedDevice(error46, recoveredRejectedDevice, connected) {
|
|
15859
|
+
return error46 instanceof DeviceRejectedError && !recoveredRejectedDevice && !connected;
|
|
15860
|
+
}
|
|
15861
|
+
async function runManagedSession(root, endpoints, options = {}) {
|
|
15862
|
+
const controller = new AbortController();
|
|
15863
|
+
const stop = () => controller.abort();
|
|
15864
|
+
const handleProcessSignals = options.handleProcessSignals ?? true;
|
|
15865
|
+
let worker;
|
|
15866
|
+
if (options.signal?.aborted) controller.abort();
|
|
15867
|
+
else options.signal?.addEventListener("abort", stop, { once: true });
|
|
15868
|
+
if (handleProcessSignals) {
|
|
15869
|
+
process.once("SIGINT", stop);
|
|
15870
|
+
process.once("SIGTERM", stop);
|
|
15871
|
+
}
|
|
15872
|
+
try {
|
|
15873
|
+
let device = await deviceForSession(
|
|
15874
|
+
endpoints,
|
|
15875
|
+
options.credentials ? { credentials: options.credentials } : {},
|
|
15876
|
+
controller.signal
|
|
15877
|
+
);
|
|
15878
|
+
controller.signal.throwIfAborted();
|
|
15879
|
+
worker = await LocalWorker.create(root);
|
|
15880
|
+
controller.signal.throwIfAborted();
|
|
15881
|
+
report(
|
|
15882
|
+
options,
|
|
15883
|
+
{ type: "session", root: worker.policy.root, deviceName: device.deviceName },
|
|
15884
|
+
`Glossa worker root: ${worker.policy.root}`
|
|
15885
|
+
);
|
|
15886
|
+
if (!options.quiet) {
|
|
15887
|
+
console.error(`Glossa device: ${device.deviceName}`);
|
|
15888
|
+
console.error(
|
|
15889
|
+
"Files may be modified and commands have the full environment and permissions of this account. Press Ctrl+C to disconnect."
|
|
15890
|
+
);
|
|
15891
|
+
}
|
|
15892
|
+
let recoveredRejectedDevice = false;
|
|
15893
|
+
while (!controller.signal.aborted) {
|
|
15894
|
+
let connected = false;
|
|
15895
|
+
try {
|
|
15896
|
+
await connectRemoteWorker(
|
|
15897
|
+
endpoints,
|
|
15898
|
+
device,
|
|
15899
|
+
worker,
|
|
15900
|
+
options,
|
|
15901
|
+
controller.signal,
|
|
15902
|
+
() => {
|
|
15903
|
+
connected = true;
|
|
15904
|
+
}
|
|
15905
|
+
);
|
|
15906
|
+
break;
|
|
15907
|
+
} catch (error46) {
|
|
15908
|
+
if (!shouldRecoverRejectedDevice(
|
|
15909
|
+
error46,
|
|
15910
|
+
recoveredRejectedDevice,
|
|
15911
|
+
connected
|
|
15912
|
+
)) {
|
|
15913
|
+
throw error46;
|
|
15914
|
+
}
|
|
15915
|
+
recoveredRejectedDevice = true;
|
|
15916
|
+
device = await reenrollRejectedDevice(
|
|
15917
|
+
endpoints,
|
|
15918
|
+
options.credentials ? { credentials: options.credentials } : {},
|
|
15919
|
+
controller.signal
|
|
15920
|
+
);
|
|
15921
|
+
}
|
|
15922
|
+
}
|
|
15923
|
+
} catch (error46) {
|
|
15924
|
+
if (error46 instanceof DeviceRejectedError) {
|
|
15925
|
+
await deleteDeviceCredential();
|
|
15926
|
+
throw new Error("The relay rejected this device. Run Glossa again to reenroll it.");
|
|
15927
|
+
}
|
|
15928
|
+
throw error46;
|
|
15929
|
+
} finally {
|
|
15930
|
+
options.signal?.removeEventListener("abort", stop);
|
|
15931
|
+
if (handleProcessSignals) {
|
|
15932
|
+
process.removeListener("SIGINT", stop);
|
|
15933
|
+
process.removeListener("SIGTERM", stop);
|
|
15934
|
+
}
|
|
15935
|
+
await worker?.shutdown();
|
|
15936
|
+
}
|
|
15937
|
+
}
|
|
15938
|
+
|
|
15939
|
+
// src/ui-hud.ts
|
|
15940
|
+
function retainPostExitNotice(current, event) {
|
|
15941
|
+
return event.type === "notice" && event.persistAfterExit ? event.message : current;
|
|
15942
|
+
}
|
|
15943
|
+
function initialHudState(workspace) {
|
|
15944
|
+
return {
|
|
15945
|
+
workspace,
|
|
15946
|
+
connection: "starting",
|
|
15947
|
+
connectedBefore: false,
|
|
15948
|
+
message: void 0,
|
|
15949
|
+
activities: [],
|
|
15950
|
+
view: "session",
|
|
15951
|
+
status: void 0,
|
|
15952
|
+
statusLoading: false,
|
|
15953
|
+
prompt: void 0,
|
|
15954
|
+
busy: false,
|
|
15955
|
+
notice: void 0
|
|
15956
|
+
};
|
|
15957
|
+
}
|
|
15958
|
+
function truncate(value, width) {
|
|
15959
|
+
if (width <= 0) return "";
|
|
15960
|
+
if (value.length <= width) return value;
|
|
15961
|
+
if (width === 1) return "\u2026";
|
|
15962
|
+
return `${value.slice(0, width - 1)}\u2026`;
|
|
15963
|
+
}
|
|
15964
|
+
function compactJobBody(job) {
|
|
15965
|
+
const entries = Object.entries(job).filter(
|
|
15966
|
+
([key]) => key !== "type" && key !== "requestId"
|
|
15967
|
+
);
|
|
15968
|
+
const body = JSON.stringify(Object.fromEntries(entries));
|
|
15969
|
+
return truncate(body, 360);
|
|
15970
|
+
}
|
|
15971
|
+
function applyHudEvent(state, event) {
|
|
15972
|
+
if (event.type === "session") {
|
|
15973
|
+
return { ...state, workspace: event.root, deviceName: event.deviceName };
|
|
15974
|
+
}
|
|
15975
|
+
if (event.type === "status") {
|
|
15976
|
+
if (event.status.state === "retrying") {
|
|
15977
|
+
return {
|
|
15978
|
+
...state,
|
|
15979
|
+
connection: "retrying",
|
|
15980
|
+
message: statusMessage(event.status, state.connectedBefore)
|
|
15981
|
+
};
|
|
15982
|
+
}
|
|
15983
|
+
return {
|
|
15984
|
+
...state,
|
|
15985
|
+
connection: event.status.state,
|
|
15986
|
+
connectedBefore: state.connectedBefore || event.status.state === "connected",
|
|
15987
|
+
message: void 0
|
|
15988
|
+
};
|
|
15989
|
+
}
|
|
15990
|
+
if (event.type === "notice") {
|
|
15991
|
+
return { ...state, notice: event.message };
|
|
15992
|
+
}
|
|
15993
|
+
const requestId = event.job.requestId;
|
|
15994
|
+
const existingIndex = state.activities.findIndex(
|
|
15995
|
+
(activity2) => activity2.requestId === requestId
|
|
15996
|
+
);
|
|
15997
|
+
const activity = {
|
|
15998
|
+
tool: event.job.type,
|
|
15999
|
+
body: compactJobBody(event.job),
|
|
16000
|
+
requestId,
|
|
16001
|
+
state: event.phase === "started" ? "working" : event.ok ? "returned" : "failed"
|
|
16002
|
+
};
|
|
16003
|
+
const activities = [...state.activities];
|
|
16004
|
+
if (existingIndex >= 0) activities[existingIndex] = activity;
|
|
16005
|
+
else activities.push(activity);
|
|
16006
|
+
return { ...state, activities: activities.slice(-20) };
|
|
16007
|
+
}
|
|
16008
|
+
var ANSI_BASE = "\x1B[22;38;2;244;241;251;48;2;17;16;22m";
|
|
16009
|
+
var PALETTE = {
|
|
16010
|
+
ink: "38;2;244;241;251",
|
|
16011
|
+
muted: "38;2;170;164;181",
|
|
16012
|
+
purple: "38;2;128;84;255",
|
|
16013
|
+
purpleReadable: "38;2;173;152;255",
|
|
16014
|
+
coral: "38;2;255;102;95",
|
|
16015
|
+
line: "38;2;92;85;110"
|
|
16016
|
+
};
|
|
16017
|
+
function style(enabled, code, value) {
|
|
16018
|
+
return enabled ? `\x1B[${code}m${value}${ANSI_BASE}` : value;
|
|
16019
|
+
}
|
|
16020
|
+
function sectionTitle(label, color, tone = PALETTE.purpleReadable) {
|
|
16021
|
+
return style(color, `${tone};1`, label.toUpperCase());
|
|
16022
|
+
}
|
|
16023
|
+
function connectionLabel(state) {
|
|
16024
|
+
if (state.connection === "connected") return "Connected";
|
|
16025
|
+
if (state.connection === "connecting" || state.connection === "starting") {
|
|
16026
|
+
return "Connecting";
|
|
16027
|
+
}
|
|
16028
|
+
if (state.connection === "retrying") return "Reconnecting";
|
|
16029
|
+
if (state.connection === "error") return "Error";
|
|
16030
|
+
return "Disconnected";
|
|
16031
|
+
}
|
|
16032
|
+
function headerLabel(state) {
|
|
16033
|
+
const running = [...state.activities].reverse().find(
|
|
16034
|
+
(activity) => activity.state === "working"
|
|
16035
|
+
);
|
|
16036
|
+
return running?.tool ?? connectionLabel(state);
|
|
16037
|
+
}
|
|
16038
|
+
function renderHeader(state, usable, color) {
|
|
16039
|
+
const brand = "Glossa";
|
|
16040
|
+
const label = truncate(
|
|
16041
|
+
headerLabel(state),
|
|
16042
|
+
Math.max(1, usable - brand.length - 1)
|
|
16043
|
+
);
|
|
16044
|
+
const gap = " ".repeat(Math.max(1, usable - brand.length - label.length));
|
|
16045
|
+
const tone = state.connection === "error" ? PALETTE.coral : PALETTE.purpleReadable;
|
|
16046
|
+
return [
|
|
16047
|
+
`${style(color, `${PALETTE.purple};1`, brand)}${gap}${style(color, `${tone};1`, label)}`,
|
|
16048
|
+
style(color, PALETTE.line, "\u2500".repeat(usable))
|
|
16049
|
+
];
|
|
16050
|
+
}
|
|
16051
|
+
function renderSession(state, usable, color) {
|
|
16052
|
+
const lines = [
|
|
16053
|
+
"",
|
|
16054
|
+
sectionTitle("Workspace", color),
|
|
16055
|
+
style(color, PALETTE.ink, truncate(state.workspace, usable))
|
|
16056
|
+
];
|
|
16057
|
+
if (state.deviceName) {
|
|
16058
|
+
lines.push(
|
|
16059
|
+
"",
|
|
16060
|
+
sectionTitle("Device", color),
|
|
16061
|
+
style(color, PALETTE.ink, truncate(state.deviceName, usable))
|
|
16062
|
+
);
|
|
16063
|
+
}
|
|
16064
|
+
if (state.message && (state.connection === "retrying" || state.connection === "error")) {
|
|
16065
|
+
lines.push(
|
|
16066
|
+
"",
|
|
16067
|
+
style(color, PALETTE.coral, truncate(state.message, usable))
|
|
16068
|
+
);
|
|
16069
|
+
}
|
|
16070
|
+
return lines;
|
|
16071
|
+
}
|
|
16072
|
+
function activityGlyph(activity, color) {
|
|
16073
|
+
if (activity.state === "working") {
|
|
16074
|
+
return style(color, PALETTE.muted, "\u25CC");
|
|
16075
|
+
}
|
|
16076
|
+
return activity.state === "failed" ? style(color, PALETTE.coral, "\xD7") : style(color, PALETTE.purpleReadable, "\u25CF");
|
|
16077
|
+
}
|
|
16078
|
+
function renderActivity(state, usable, color, bodyBudget) {
|
|
16079
|
+
const lines = ["", sectionTitle("Recent activity", color)];
|
|
16080
|
+
if (state.activities.length === 0) {
|
|
16081
|
+
lines.push("", style(color, PALETTE.muted, "No activity yet."));
|
|
16082
|
+
return lines;
|
|
16083
|
+
}
|
|
16084
|
+
const visibleEntryCount = Math.min(
|
|
16085
|
+
8,
|
|
16086
|
+
Math.max(0, Math.floor((bodyBudget - lines.length) / 3))
|
|
16087
|
+
);
|
|
16088
|
+
if (visibleEntryCount === 0) return lines;
|
|
16089
|
+
for (const activity of state.activities.slice(-visibleEntryCount)) {
|
|
16090
|
+
lines.push(
|
|
16091
|
+
"",
|
|
16092
|
+
`${activityGlyph(activity, color)} ${style(color, `${PALETTE.ink};1`, truncate(activity.tool, Math.max(1, usable - 2)))}`,
|
|
16093
|
+
style(color, PALETTE.muted, truncate(activity.body, usable))
|
|
16094
|
+
);
|
|
16095
|
+
}
|
|
16096
|
+
return lines;
|
|
16097
|
+
}
|
|
16098
|
+
function metric(label, value, usable, color) {
|
|
16099
|
+
const visibleValue = truncate(value, Math.max(1, usable - 1));
|
|
16100
|
+
const visibleLabel = truncate(
|
|
16101
|
+
label,
|
|
16102
|
+
Math.max(0, usable - visibleValue.length - 1)
|
|
16103
|
+
);
|
|
16104
|
+
const gap = " ".repeat(
|
|
16105
|
+
Math.max(1, usable - visibleLabel.length - visibleValue.length)
|
|
16106
|
+
);
|
|
16107
|
+
return `${style(color, PALETTE.muted, visibleLabel)}${gap}${style(color, PALETTE.ink, visibleValue)}`;
|
|
16108
|
+
}
|
|
16109
|
+
function tableCell(value, width) {
|
|
16110
|
+
return truncate(value, width).padEnd(width);
|
|
16111
|
+
}
|
|
16112
|
+
function renderDeviceRows(device, index, usable, color) {
|
|
16113
|
+
const number4 = String(index + 1).padStart(2);
|
|
16114
|
+
const statusTone = device.status.includes("active") ? PALETTE.purpleReadable : PALETTE.muted;
|
|
16115
|
+
if (usable < 64) {
|
|
16116
|
+
const prefix = `${number4} `;
|
|
16117
|
+
const details = `${device.name} \xB7 ${device.status} \xB7 ${device.platform} \xB7 ${device.lastSeen}`;
|
|
16118
|
+
return [
|
|
16119
|
+
`${style(color, `${PALETTE.purpleReadable};1`, number4)} ${style(
|
|
16120
|
+
color,
|
|
16121
|
+
statusTone,
|
|
16122
|
+
truncate(details, Math.max(1, usable - prefix.length))
|
|
16123
|
+
)}`
|
|
16124
|
+
];
|
|
16125
|
+
}
|
|
16126
|
+
const statusWidth = 16;
|
|
16127
|
+
const platformWidth = 12;
|
|
16128
|
+
const lastSeenWidth = Math.min(
|
|
16129
|
+
18,
|
|
16130
|
+
Math.max(10, Math.floor(usable * 0.18))
|
|
16131
|
+
);
|
|
16132
|
+
const nameWidth = usable - 38 - lastSeenWidth;
|
|
16133
|
+
return [
|
|
16134
|
+
`${style(color, `${PALETTE.purpleReadable};1`, number4)} ${style(color, PALETTE.ink, tableCell(device.name, nameWidth))} ${style(color, statusTone, tableCell(device.status, statusWidth))} ${style(color, PALETTE.muted, tableCell(device.platform, platformWidth))} ${style(color, PALETTE.muted, tableCell(device.lastSeen, lastSeenWidth))}`
|
|
16135
|
+
];
|
|
16136
|
+
}
|
|
16137
|
+
function deviceTableHeading(usable, color) {
|
|
16138
|
+
if (usable < 64) return void 0;
|
|
16139
|
+
const statusWidth = 16;
|
|
16140
|
+
const platformWidth = 12;
|
|
16141
|
+
const lastSeenWidth = Math.min(
|
|
16142
|
+
18,
|
|
16143
|
+
Math.max(10, Math.floor(usable * 0.18))
|
|
16144
|
+
);
|
|
16145
|
+
const nameWidth = usable - 38 - lastSeenWidth;
|
|
16146
|
+
return style(
|
|
16147
|
+
color,
|
|
16148
|
+
PALETTE.muted,
|
|
16149
|
+
` ${tableCell("Device", nameWidth)} ${tableCell("Workers", statusWidth)} ${tableCell("Platform", platformWidth)} ${tableCell("Last seen", lastSeenWidth)}`
|
|
16150
|
+
);
|
|
16151
|
+
}
|
|
16152
|
+
function renderStatus(state, usable, color, visibleDeviceCount) {
|
|
16153
|
+
const lines = ["", sectionTitle("Account", color)];
|
|
16154
|
+
if (state.statusLoading) {
|
|
16155
|
+
lines.push("", style(color, PALETTE.muted, "Loading status\u2026"));
|
|
16156
|
+
return lines;
|
|
15262
16157
|
}
|
|
15263
|
-
if (
|
|
15264
|
-
|
|
16158
|
+
if (!state.status) {
|
|
16159
|
+
lines.push("", style(color, PALETTE.muted, "Status is not loaded."));
|
|
16160
|
+
return lines;
|
|
15265
16161
|
}
|
|
15266
|
-
|
|
15267
|
-
|
|
16162
|
+
const workerCount = state.status.activeWorkers === null ? "Unavailable" : String(state.status.activeWorkers);
|
|
16163
|
+
lines.push(
|
|
16164
|
+
style(color, PALETTE.ink, truncate(state.status.account, usable)),
|
|
16165
|
+
style(color, PALETTE.muted, truncate(state.status.relay, usable)),
|
|
16166
|
+
"",
|
|
16167
|
+
sectionTitle("Overview", color),
|
|
16168
|
+
metric("Active workspaces", workerCount, usable, color),
|
|
16169
|
+
metric("Devices", String(state.status.devices.length), usable, color),
|
|
16170
|
+
"",
|
|
16171
|
+
sectionTitle("Devices", color)
|
|
16172
|
+
);
|
|
16173
|
+
if (state.status.devices.length === 0) {
|
|
16174
|
+
lines.push("", style(color, PALETTE.muted, "No active devices."));
|
|
16175
|
+
return lines;
|
|
16176
|
+
}
|
|
16177
|
+
const heading = deviceTableHeading(usable, color);
|
|
16178
|
+
if (heading) lines.push(heading);
|
|
16179
|
+
state.status.devices.slice(0, visibleDeviceCount).forEach((device, index) => {
|
|
16180
|
+
lines.push(...renderDeviceRows(device, index, usable, color));
|
|
16181
|
+
});
|
|
16182
|
+
const hiddenCount = state.status.devices.length - visibleDeviceCount;
|
|
16183
|
+
if (hiddenCount > 0) {
|
|
16184
|
+
lines.push(
|
|
16185
|
+
style(
|
|
16186
|
+
color,
|
|
16187
|
+
PALETTE.muted,
|
|
16188
|
+
truncate(
|
|
16189
|
+
`${hiddenCount} more. Use glossa devices revoke <id>.`,
|
|
16190
|
+
usable
|
|
16191
|
+
)
|
|
16192
|
+
)
|
|
16193
|
+
);
|
|
15268
16194
|
}
|
|
15269
|
-
return
|
|
16195
|
+
return lines;
|
|
15270
16196
|
}
|
|
15271
|
-
function
|
|
15272
|
-
|
|
15273
|
-
|
|
16197
|
+
function helpRows(key, label, usable, color, tone = PALETTE.purpleReadable) {
|
|
16198
|
+
const available = Math.max(1, usable - key.length - 2);
|
|
16199
|
+
return [
|
|
16200
|
+
`${style(color, `${tone};1`, key)} ${truncate(label, available)}`
|
|
16201
|
+
];
|
|
15274
16202
|
}
|
|
15275
|
-
function
|
|
15276
|
-
return
|
|
15277
|
-
|
|
15278
|
-
|
|
15279
|
-
|
|
15280
|
-
|
|
15281
|
-
|
|
15282
|
-
|
|
15283
|
-
|
|
15284
|
-
|
|
15285
|
-
|
|
15286
|
-
|
|
15287
|
-
|
|
15288
|
-
|
|
15289
|
-
|
|
15290
|
-
|
|
15291
|
-
|
|
15292
|
-
|
|
15293
|
-
|
|
15294
|
-
|
|
15295
|
-
|
|
15296
|
-
|
|
15297
|
-
|
|
15298
|
-
|
|
15299
|
-
|
|
15300
|
-
|
|
15301
|
-
|
|
16203
|
+
function renderHelp(usable, color) {
|
|
16204
|
+
return [
|
|
16205
|
+
"",
|
|
16206
|
+
sectionTitle("Navigate", color),
|
|
16207
|
+
...helpRows("D", "Recent activity", usable, color),
|
|
16208
|
+
...helpRows("S", "Account and devices", usable, color),
|
|
16209
|
+
...helpRows("?", "Close help", usable, color),
|
|
16210
|
+
"",
|
|
16211
|
+
sectionTitle("Manage", color, PALETTE.coral),
|
|
16212
|
+
...helpRows(
|
|
16213
|
+
"R",
|
|
16214
|
+
"Revoke a device from status",
|
|
16215
|
+
usable,
|
|
16216
|
+
color,
|
|
16217
|
+
PALETTE.coral
|
|
16218
|
+
),
|
|
16219
|
+
...helpRows("L", "Sign out", usable, color, PALETTE.coral),
|
|
16220
|
+
"",
|
|
16221
|
+
sectionTitle("Session", color),
|
|
16222
|
+
...helpRows("Q", "Disconnect and quit", usable, color, PALETTE.coral),
|
|
16223
|
+
...helpRows(
|
|
16224
|
+
"Ctrl+C",
|
|
16225
|
+
"Disconnect and quit",
|
|
16226
|
+
usable,
|
|
16227
|
+
color,
|
|
16228
|
+
PALETTE.coral
|
|
16229
|
+
)
|
|
16230
|
+
];
|
|
15302
16231
|
}
|
|
15303
|
-
|
|
15304
|
-
|
|
15305
|
-
|
|
15306
|
-
|
|
15307
|
-
|
|
15308
|
-
const ownsDevice = dependencies.accountOwnsDevice ?? accountOwnsDevice;
|
|
15309
|
-
const enroll = dependencies.enrollDevice ?? enrollDevice;
|
|
15310
|
-
const saveDevice = dependencies.saveDeviceCredential ?? saveDeviceCredential;
|
|
15311
|
-
const name = dependencies.defaultDeviceName ?? defaultDeviceName;
|
|
15312
|
-
const baseFetch = dependencies.fetch ?? fetch;
|
|
15313
|
-
const fetchRequest = signal ? async (input, init) => await baseFetch(input, { ...init, signal }) : baseFetch;
|
|
15314
|
-
signal?.throwIfAborted();
|
|
15315
|
-
const stored = await loadDevice();
|
|
15316
|
-
const loaded = await loadLogin();
|
|
15317
|
-
if (!loaded) throw new Error("Not signed in. Run Glossa again to sign in.");
|
|
15318
|
-
const credentials = await validate(loaded.credentials, { fetch: fetchRequest });
|
|
15319
|
-
signal?.throwIfAborted();
|
|
15320
|
-
if (stored?.relayOrigin === endpoints.relayOrigin) {
|
|
15321
|
-
if (await ownsDevice(
|
|
15322
|
-
endpoints,
|
|
15323
|
-
credentials,
|
|
15324
|
-
stored.deviceId,
|
|
15325
|
-
fetchRequest
|
|
15326
|
-
)) {
|
|
15327
|
-
return stored;
|
|
15328
|
-
}
|
|
15329
|
-
await removeDevice();
|
|
16232
|
+
function promptText(state) {
|
|
16233
|
+
if (state.busy) return { message: "Working\u2026" };
|
|
16234
|
+
if (!state.prompt) return void 0;
|
|
16235
|
+
if (state.prompt.type === "logout") {
|
|
16236
|
+
return { message: "Sign out and disconnect?", choices: "Y confirm N cancel" };
|
|
15330
16237
|
}
|
|
15331
|
-
|
|
15332
|
-
|
|
15333
|
-
|
|
15334
|
-
|
|
15335
|
-
|
|
16238
|
+
if (state.prompt.type === "revoke-select") {
|
|
16239
|
+
return { message: "Choose a device number to revoke.", choices: "Esc cancel" };
|
|
16240
|
+
}
|
|
16241
|
+
const device = state.status?.devices[state.prompt.deviceIndex];
|
|
16242
|
+
return {
|
|
16243
|
+
message: `Revoke ${device?.name ?? "this device"}?`,
|
|
16244
|
+
choices: "Y confirm N cancel"
|
|
16245
|
+
};
|
|
16246
|
+
}
|
|
16247
|
+
function footerHints(state) {
|
|
16248
|
+
if (state.view === "status") {
|
|
16249
|
+
return [
|
|
16250
|
+
{ key: "R", label: "Revoke", tone: PALETTE.coral },
|
|
16251
|
+
{ key: "L", label: "Sign out", tone: PALETTE.coral },
|
|
16252
|
+
{ key: "Esc", label: "Session" },
|
|
16253
|
+
{ key: "Q", label: "Quit", tone: PALETTE.coral }
|
|
16254
|
+
];
|
|
16255
|
+
}
|
|
16256
|
+
if (state.view === "activity") {
|
|
16257
|
+
return [
|
|
16258
|
+
{ key: "D", label: "Session" },
|
|
16259
|
+
{ key: "S", label: "Status" },
|
|
16260
|
+
{ key: "?", label: "Help" },
|
|
16261
|
+
{ key: "Q", label: "Quit", tone: PALETTE.coral }
|
|
16262
|
+
];
|
|
16263
|
+
}
|
|
16264
|
+
if (state.view === "help") {
|
|
16265
|
+
return [
|
|
16266
|
+
{ key: "?", label: "Session" },
|
|
16267
|
+
{ key: "Q", label: "Quit", tone: PALETTE.coral }
|
|
16268
|
+
];
|
|
16269
|
+
}
|
|
16270
|
+
return [
|
|
16271
|
+
{ key: "D", label: "Activity" },
|
|
16272
|
+
{ key: "S", label: "Status" },
|
|
16273
|
+
{ key: "?", label: "Help" },
|
|
16274
|
+
{ key: "L", label: "Sign out", tone: PALETTE.coral },
|
|
16275
|
+
{ key: "Q", label: "Quit", tone: PALETTE.coral }
|
|
16276
|
+
];
|
|
16277
|
+
}
|
|
16278
|
+
function renderFooter(state, usable, color) {
|
|
16279
|
+
const rows = [[]];
|
|
16280
|
+
let rowLength = 0;
|
|
16281
|
+
for (const hint of footerHints(state)) {
|
|
16282
|
+
const tokenLength = hint.key.length + hint.label.length + 1;
|
|
16283
|
+
if (rows.at(-1).length > 0 && rowLength + 3 + tokenLength > usable) {
|
|
16284
|
+
rows.push([]);
|
|
16285
|
+
rowLength = 0;
|
|
16286
|
+
}
|
|
16287
|
+
rows.at(-1).push(hint);
|
|
16288
|
+
rowLength += (rowLength > 0 ? 3 : 0) + tokenLength;
|
|
16289
|
+
}
|
|
16290
|
+
return rows.map(
|
|
16291
|
+
(row) => row.map(
|
|
16292
|
+
(hint) => `${style(color, `${hint.tone ?? PALETTE.purpleReadable};1`, hint.key)} ${style(color, PALETTE.muted, hint.label)}`
|
|
16293
|
+
).join(" ")
|
|
15336
16294
|
);
|
|
15337
|
-
await saveDevice(enrolled);
|
|
15338
|
-
return enrolled;
|
|
15339
16295
|
}
|
|
15340
|
-
function
|
|
15341
|
-
|
|
15342
|
-
|
|
15343
|
-
|
|
15344
|
-
|
|
15345
|
-
|
|
15346
|
-
|
|
15347
|
-
|
|
16296
|
+
function renderOverlay(state, usable, color) {
|
|
16297
|
+
const prompt = promptText(state);
|
|
16298
|
+
const message = prompt?.message ?? state.notice;
|
|
16299
|
+
if (!message) return [];
|
|
16300
|
+
const lines = [
|
|
16301
|
+
style(color, PALETTE.line, "\u2500".repeat(usable)),
|
|
16302
|
+
style(
|
|
16303
|
+
color,
|
|
16304
|
+
prompt ? `${PALETTE.coral};1` : PALETTE.coral,
|
|
16305
|
+
truncate(message, usable)
|
|
16306
|
+
)
|
|
16307
|
+
];
|
|
16308
|
+
if (prompt?.choices) {
|
|
16309
|
+
lines.push(style(color, PALETTE.muted, truncate(prompt.choices, usable)));
|
|
15348
16310
|
}
|
|
15349
|
-
return
|
|
16311
|
+
return lines;
|
|
15350
16312
|
}
|
|
15351
|
-
|
|
15352
|
-
const
|
|
15353
|
-
const
|
|
15354
|
-
const
|
|
15355
|
-
|
|
15356
|
-
|
|
15357
|
-
|
|
15358
|
-
|
|
15359
|
-
|
|
15360
|
-
|
|
16313
|
+
function renderHud(state, width = 80, color = !process.env.NO_COLOR, height = 24) {
|
|
16314
|
+
const margin = width >= 24 ? " " : "";
|
|
16315
|
+
const usable = Math.max(8, width - margin.length * 2);
|
|
16316
|
+
const terminalHeight = Math.max(6, height);
|
|
16317
|
+
const header = renderHeader(state, usable, color);
|
|
16318
|
+
const overlay = renderOverlay(state, usable, color);
|
|
16319
|
+
const footer = [
|
|
16320
|
+
style(color, PALETTE.line, "\u2500".repeat(usable)),
|
|
16321
|
+
...renderFooter(state, usable, color)
|
|
16322
|
+
];
|
|
16323
|
+
const bodyBudget = Math.max(
|
|
16324
|
+
0,
|
|
16325
|
+
terminalHeight - header.length - overlay.length - footer.length
|
|
16326
|
+
);
|
|
16327
|
+
const visibleDeviceCount = statusDeviceCapacity(
|
|
16328
|
+
state,
|
|
16329
|
+
bodyBudget,
|
|
16330
|
+
usable
|
|
16331
|
+
);
|
|
16332
|
+
const body = state.view === "activity" ? renderActivity(state, usable, color, bodyBudget) : state.view === "status" ? renderStatus(state, usable, color, visibleDeviceCount) : state.view === "help" ? renderHelp(usable, color) : renderSession(state, usable, color);
|
|
16333
|
+
const visibleBody = body.slice(0, bodyBudget);
|
|
16334
|
+
const padding = Array.from(
|
|
16335
|
+
{
|
|
16336
|
+
length: Math.max(
|
|
16337
|
+
0,
|
|
16338
|
+
terminalHeight - header.length - visibleBody.length - overlay.length - footer.length
|
|
16339
|
+
)
|
|
16340
|
+
},
|
|
16341
|
+
() => ""
|
|
16342
|
+
);
|
|
16343
|
+
const lines = [...header, ...visibleBody, ...padding, ...overlay, ...footer].slice(-terminalHeight);
|
|
16344
|
+
return lines.map((line) => line ? `${margin}${line}` : "").join("\n");
|
|
16345
|
+
}
|
|
16346
|
+
function statusDeviceCapacity(state, bodyBudget, usable) {
|
|
16347
|
+
if (state.view !== "status" || !state.status || state.statusLoading || state.status.devices.length === 0) {
|
|
16348
|
+
return 0;
|
|
16349
|
+
}
|
|
16350
|
+
const statusPreambleLines = usable >= 64 ? 11 : 10;
|
|
16351
|
+
const available = Math.max(0, bodyBudget - statusPreambleLines);
|
|
16352
|
+
let visible = Math.min(9, state.status.devices.length, available);
|
|
16353
|
+
if (state.status.devices.length > visible && visible > 0 && available - visible < 1) {
|
|
16354
|
+
visible -= 1;
|
|
16355
|
+
}
|
|
16356
|
+
return visible;
|
|
16357
|
+
}
|
|
16358
|
+
function terminalStatusDeviceCapacity(state, width, height) {
|
|
16359
|
+
const marginLength = width >= 24 ? 4 : 0;
|
|
16360
|
+
const usable = Math.max(8, width - marginLength);
|
|
16361
|
+
const terminalHeight = Math.max(6, height);
|
|
16362
|
+
const bodyBudget = Math.max(
|
|
16363
|
+
0,
|
|
16364
|
+
terminalHeight - renderHeader(state, usable, false).length - renderOverlay(state, usable, false).length - 1 - renderFooter(state, usable, false).length
|
|
16365
|
+
);
|
|
16366
|
+
return statusDeviceCapacity(state, bodyBudget, usable);
|
|
16367
|
+
}
|
|
16368
|
+
async function runSessionHud(actions, input = process.stdin, output = process.stdout) {
|
|
16369
|
+
if (!input.isTTY || !output.isTTY) {
|
|
16370
|
+
throw new Error("Glossa requires an interactive terminal.");
|
|
15361
16371
|
}
|
|
15362
|
-
|
|
15363
|
-
|
|
15364
|
-
|
|
15365
|
-
|
|
15366
|
-
|
|
15367
|
-
|
|
15368
|
-
|
|
15369
|
-
|
|
15370
|
-
|
|
15371
|
-
|
|
15372
|
-
|
|
15373
|
-
|
|
15374
|
-
|
|
16372
|
+
emitKeypressEvents(input);
|
|
16373
|
+
const wasRaw = input.isRaw;
|
|
16374
|
+
const controller = new AbortController();
|
|
16375
|
+
let state = initialHudState(actions.workspace);
|
|
16376
|
+
let exitAction = "quit";
|
|
16377
|
+
let stopUi;
|
|
16378
|
+
const color = !process.env.NO_COLOR;
|
|
16379
|
+
const render = () => {
|
|
16380
|
+
const view = renderHud(
|
|
16381
|
+
state,
|
|
16382
|
+
output.columns ?? 80,
|
|
16383
|
+
color,
|
|
16384
|
+
output.rows ?? 24
|
|
15375
16385
|
);
|
|
15376
|
-
|
|
15377
|
-
|
|
15378
|
-
|
|
15379
|
-
|
|
16386
|
+
output.write(`${color ? ANSI_BASE : ""}\x1B[H\x1B[2J${view}`);
|
|
16387
|
+
};
|
|
16388
|
+
const resize = () => {
|
|
16389
|
+
if (state.prompt?.type === "revoke-select" || state.prompt?.type === "revoke-confirm") {
|
|
16390
|
+
const deviceCount = terminalStatusDeviceCapacity(
|
|
16391
|
+
state,
|
|
16392
|
+
output.columns ?? 80,
|
|
16393
|
+
output.rows ?? 24
|
|
15380
16394
|
);
|
|
15381
|
-
|
|
15382
|
-
|
|
15383
|
-
|
|
15384
|
-
|
|
15385
|
-
|
|
15386
|
-
|
|
15387
|
-
|
|
15388
|
-
|
|
15389
|
-
|
|
15390
|
-
|
|
15391
|
-
|
|
15392
|
-
|
|
15393
|
-
}
|
|
15394
|
-
if (status.state === "connected" && status.legacyRelay) {
|
|
15395
|
-
report(
|
|
15396
|
-
options,
|
|
15397
|
-
{ type: "notice", message: "The relay needs an update before this computer can expose several workspaces at once." },
|
|
15398
|
-
"The relay needs an update before this computer can expose several workspaces at once."
|
|
15399
|
-
);
|
|
15400
|
-
}
|
|
15401
|
-
if (status.state === "connected" && !status.reconnected && shouldShowConnectHint(endpoints.relayOrigin)) {
|
|
15402
|
-
void announceConnectHint(connectHintStore(), (message) => {
|
|
15403
|
-
report(options, { type: "notice", message }, message);
|
|
15404
|
-
}).catch(() => void 0);
|
|
15405
|
-
}
|
|
15406
|
-
connectionState = status.state;
|
|
16395
|
+
const selectedDeviceIsHidden = state.prompt.type === "revoke-confirm" && state.prompt.deviceIndex >= deviceCount;
|
|
16396
|
+
if (deviceCount === 0 || selectedDeviceIsHidden) {
|
|
16397
|
+
state = {
|
|
16398
|
+
...state,
|
|
16399
|
+
prompt: void 0,
|
|
16400
|
+
notice: "Increase the terminal height to choose a device."
|
|
16401
|
+
};
|
|
16402
|
+
} else if (state.prompt.type === "revoke-select") {
|
|
16403
|
+
state = {
|
|
16404
|
+
...state,
|
|
16405
|
+
prompt: { type: "revoke-select", deviceCount }
|
|
16406
|
+
};
|
|
15407
16407
|
}
|
|
15408
|
-
}).run();
|
|
15409
|
-
} catch (error46) {
|
|
15410
|
-
if (error46 instanceof DeviceRejectedError) {
|
|
15411
|
-
await deleteDeviceCredential();
|
|
15412
|
-
throw new Error("The relay rejected this device. Run Glossa again to reenroll it.");
|
|
15413
16408
|
}
|
|
16409
|
+
render();
|
|
16410
|
+
};
|
|
16411
|
+
const loadStatus = async () => {
|
|
16412
|
+
if (state.statusLoading) return;
|
|
16413
|
+
if (state.connection !== "connected" && state.connection !== "retrying") {
|
|
16414
|
+
state = { ...state, notice: "Status is available after Glossa connects." };
|
|
16415
|
+
render();
|
|
16416
|
+
return;
|
|
16417
|
+
}
|
|
16418
|
+
state = {
|
|
16419
|
+
...state,
|
|
16420
|
+
view: "status",
|
|
16421
|
+
statusLoading: true,
|
|
16422
|
+
prompt: void 0,
|
|
16423
|
+
notice: void 0
|
|
16424
|
+
};
|
|
16425
|
+
render();
|
|
16426
|
+
try {
|
|
16427
|
+
const status = await actions.loadStatus(controller.signal);
|
|
16428
|
+
if (controller.signal.aborted) return;
|
|
16429
|
+
state = { ...state, status, statusLoading: false };
|
|
16430
|
+
} catch (error46) {
|
|
16431
|
+
if (controller.signal.aborted) return;
|
|
16432
|
+
state = {
|
|
16433
|
+
...state,
|
|
16434
|
+
statusLoading: false,
|
|
16435
|
+
notice: error46 instanceof Error ? error46.message : String(error46)
|
|
16436
|
+
};
|
|
16437
|
+
}
|
|
16438
|
+
render();
|
|
16439
|
+
};
|
|
16440
|
+
const session = actions.run(controller.signal, (event) => {
|
|
16441
|
+
state = applyHudEvent(state, event);
|
|
16442
|
+
render();
|
|
16443
|
+
}).then(() => {
|
|
16444
|
+
if (!controller.signal.aborted) {
|
|
16445
|
+
state = { ...state, connection: "disconnected" };
|
|
16446
|
+
}
|
|
16447
|
+
render();
|
|
16448
|
+
}).catch((error46) => {
|
|
16449
|
+
if (controller.signal.aborted) return;
|
|
16450
|
+
state = {
|
|
16451
|
+
...state,
|
|
16452
|
+
connection: "error",
|
|
16453
|
+
message: error46 instanceof Error ? error46.message : String(error46)
|
|
16454
|
+
};
|
|
16455
|
+
render();
|
|
15414
16456
|
throw error46;
|
|
16457
|
+
});
|
|
16458
|
+
input.setRawMode(true);
|
|
16459
|
+
input.resume();
|
|
16460
|
+
output.write("\x1B[?1049h\x1B[?25l");
|
|
16461
|
+
output.on("resize", resize);
|
|
16462
|
+
render();
|
|
16463
|
+
const stop = (action = "quit") => {
|
|
16464
|
+
exitAction = action;
|
|
16465
|
+
controller.abort();
|
|
16466
|
+
stopUi?.();
|
|
16467
|
+
};
|
|
16468
|
+
const stopFromSignal = () => stop();
|
|
16469
|
+
process.once("SIGINT", stopFromSignal);
|
|
16470
|
+
process.once("SIGTERM", stopFromSignal);
|
|
16471
|
+
try {
|
|
16472
|
+
await new Promise((resolve) => {
|
|
16473
|
+
const onKeypress = (value, key) => {
|
|
16474
|
+
if (key.ctrl && key.name === "c" || key.name === "q") return stop();
|
|
16475
|
+
if (state.busy) return;
|
|
16476
|
+
if (state.prompt) {
|
|
16477
|
+
if (key.name === "escape" || key.name === "n") {
|
|
16478
|
+
state = { ...state, prompt: void 0, notice: void 0 };
|
|
16479
|
+
render();
|
|
16480
|
+
return;
|
|
16481
|
+
}
|
|
16482
|
+
if (state.prompt.type === "revoke-select") {
|
|
16483
|
+
const deviceIndex = Number(value) - 1;
|
|
16484
|
+
if (Number.isInteger(deviceIndex) && deviceIndex >= 0 && deviceIndex < state.prompt.deviceCount) {
|
|
16485
|
+
state = {
|
|
16486
|
+
...state,
|
|
16487
|
+
prompt: { type: "revoke-confirm", deviceIndex }
|
|
16488
|
+
};
|
|
16489
|
+
render();
|
|
16490
|
+
}
|
|
16491
|
+
return;
|
|
16492
|
+
}
|
|
16493
|
+
if (key.name !== "y") return;
|
|
16494
|
+
if (state.prompt.type === "logout") return stop("logout");
|
|
16495
|
+
const device = state.status?.devices[state.prompt.deviceIndex];
|
|
16496
|
+
if (!device) return;
|
|
16497
|
+
state = {
|
|
16498
|
+
...state,
|
|
16499
|
+
busy: true,
|
|
16500
|
+
prompt: void 0,
|
|
16501
|
+
notice: void 0
|
|
16502
|
+
};
|
|
16503
|
+
render();
|
|
16504
|
+
void actions.revokeDevice(device.id, controller.signal).then(
|
|
16505
|
+
async () => {
|
|
16506
|
+
if (controller.signal.aborted) return;
|
|
16507
|
+
state = {
|
|
16508
|
+
...state,
|
|
16509
|
+
busy: false
|
|
16510
|
+
};
|
|
16511
|
+
await loadStatus();
|
|
16512
|
+
if (controller.signal.aborted) return;
|
|
16513
|
+
state = {
|
|
16514
|
+
...state,
|
|
16515
|
+
notice: `Revoked ${device.name}.`
|
|
16516
|
+
};
|
|
16517
|
+
render();
|
|
16518
|
+
}
|
|
16519
|
+
).catch((error46) => {
|
|
16520
|
+
if (controller.signal.aborted) return;
|
|
16521
|
+
state = {
|
|
16522
|
+
...state,
|
|
16523
|
+
busy: false,
|
|
16524
|
+
notice: error46 instanceof Error ? error46.message : String(error46)
|
|
16525
|
+
};
|
|
16526
|
+
render();
|
|
16527
|
+
});
|
|
16528
|
+
return;
|
|
16529
|
+
}
|
|
16530
|
+
if (key.name === "escape") {
|
|
16531
|
+
state = { ...state, view: "session", notice: void 0 };
|
|
16532
|
+
render();
|
|
16533
|
+
} else if (key.name === "d") {
|
|
16534
|
+
state = {
|
|
16535
|
+
...state,
|
|
16536
|
+
view: state.view === "activity" ? "session" : "activity",
|
|
16537
|
+
notice: void 0
|
|
16538
|
+
};
|
|
16539
|
+
render();
|
|
16540
|
+
} else if (key.name === "s") {
|
|
16541
|
+
void loadStatus();
|
|
16542
|
+
} else if (key.name === "r" && state.view === "status") {
|
|
16543
|
+
if ((state.status?.devices.length ?? 0) === 0) {
|
|
16544
|
+
state = { ...state, notice: "There are no devices to revoke." };
|
|
16545
|
+
} else {
|
|
16546
|
+
const promptState = {
|
|
16547
|
+
...state,
|
|
16548
|
+
prompt: { type: "revoke-select", deviceCount: 0 },
|
|
16549
|
+
notice: void 0
|
|
16550
|
+
};
|
|
16551
|
+
const deviceCount = terminalStatusDeviceCapacity(
|
|
16552
|
+
promptState,
|
|
16553
|
+
output.columns ?? 80,
|
|
16554
|
+
output.rows ?? 24
|
|
16555
|
+
);
|
|
16556
|
+
state = deviceCount === 0 ? {
|
|
16557
|
+
...state,
|
|
16558
|
+
notice: "Increase the terminal height to choose a device."
|
|
16559
|
+
} : {
|
|
16560
|
+
...promptState,
|
|
16561
|
+
prompt: { type: "revoke-select", deviceCount }
|
|
16562
|
+
};
|
|
16563
|
+
}
|
|
16564
|
+
render();
|
|
16565
|
+
} else if (key.name === "l") {
|
|
16566
|
+
state = {
|
|
16567
|
+
...state,
|
|
16568
|
+
prompt: { type: "logout" },
|
|
16569
|
+
notice: void 0
|
|
16570
|
+
};
|
|
16571
|
+
render();
|
|
16572
|
+
} else if (value === "?" || key.sequence === "?") {
|
|
16573
|
+
state = {
|
|
16574
|
+
...state,
|
|
16575
|
+
view: state.view === "help" ? "session" : "help",
|
|
16576
|
+
notice: void 0
|
|
16577
|
+
};
|
|
16578
|
+
render();
|
|
16579
|
+
}
|
|
16580
|
+
};
|
|
16581
|
+
input.on("keypress", onKeypress);
|
|
16582
|
+
void session.catch(() => stopUi?.());
|
|
16583
|
+
stopUi = () => {
|
|
16584
|
+
input.removeListener("keypress", onKeypress);
|
|
16585
|
+
resolve();
|
|
16586
|
+
};
|
|
16587
|
+
});
|
|
16588
|
+
await session;
|
|
16589
|
+
return exitAction;
|
|
15415
16590
|
} finally {
|
|
15416
|
-
|
|
15417
|
-
|
|
15418
|
-
|
|
15419
|
-
|
|
15420
|
-
|
|
15421
|
-
|
|
16591
|
+
output.removeListener("resize", resize);
|
|
16592
|
+
process.removeListener("SIGINT", stopFromSignal);
|
|
16593
|
+
process.removeListener("SIGTERM", stopFromSignal);
|
|
16594
|
+
input.setRawMode(wasRaw);
|
|
16595
|
+
input.pause();
|
|
16596
|
+
output.write("\x1B[0m\x1B[?25h\x1B[?1049l");
|
|
15422
16597
|
}
|
|
15423
16598
|
}
|
|
15424
16599
|
|
|
15425
16600
|
// src/worker/root-selection.ts
|
|
15426
|
-
import {
|
|
15427
|
-
import
|
|
15428
|
-
|
|
15429
|
-
|
|
15430
|
-
|
|
15431
|
-
|
|
15432
|
-
|
|
15433
|
-
|
|
15434
|
-
|
|
15435
|
-
|
|
15436
|
-
|
|
15437
|
-
|
|
15438
|
-
|
|
15439
|
-
|
|
16601
|
+
import { realpath as realpath2 } from "node:fs/promises";
|
|
16602
|
+
import os4 from "node:os";
|
|
16603
|
+
import path7 from "node:path";
|
|
16604
|
+
function containsPath(root, candidate) {
|
|
16605
|
+
const relative = path7.relative(root, candidate);
|
|
16606
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path7.sep}`) && !path7.isAbsolute(relative);
|
|
16607
|
+
}
|
|
16608
|
+
async function rejectImplicitHomeAncestor(root) {
|
|
16609
|
+
const homes = await Promise.all(
|
|
16610
|
+
[os4.homedir(), accountHomeDirectory()].map(
|
|
16611
|
+
async (home) => await realpath2(home).catch(() => path7.resolve(home))
|
|
16612
|
+
)
|
|
16613
|
+
);
|
|
16614
|
+
if (homes.some((home) => containsPath(root, home))) {
|
|
16615
|
+
throw new WorkerError(
|
|
16616
|
+
"broad_root_refused",
|
|
16617
|
+
"The selected root contains a home directory, which Glossa will not expose implicitly. Choose a workspace directory instead."
|
|
16618
|
+
);
|
|
15440
16619
|
}
|
|
15441
16620
|
}
|
|
15442
|
-
async function selectExposureRoot(explicitPath,
|
|
15443
|
-
const
|
|
15444
|
-
|
|
16621
|
+
async function selectExposureRoot(explicitPath, cwd = process.cwd()) {
|
|
16622
|
+
const root = await canonicalizeRoot(explicitPath ?? cwd);
|
|
16623
|
+
if (explicitPath === void 0) await rejectImplicitHomeAncestor(root);
|
|
16624
|
+
return root;
|
|
15445
16625
|
}
|
|
15446
16626
|
|
|
15447
16627
|
// src/main.ts
|
|
15448
|
-
var VERSION = "0.1.0-beta.
|
|
15449
|
-
var
|
|
15450
|
-
main: `Glossa ${VERSION}
|
|
16628
|
+
var VERSION = "0.1.0-beta.12";
|
|
16629
|
+
var HELP = `Glossa ${VERSION}
|
|
15451
16630
|
|
|
15452
16631
|
Usage:
|
|
15453
|
-
glossa
|
|
15454
|
-
glossa
|
|
15455
|
-
glossa ui [directory] [--allow-broad-root] [--device-name <name>]
|
|
15456
|
-
glossa start [directory] [--allow-broad-root] [--device-name <name>]
|
|
15457
|
-
glossa status [--json]
|
|
15458
|
-
glossa doctor [--json]
|
|
15459
|
-
glossa devices list [--json]
|
|
15460
|
-
glossa devices rename <id> <name>
|
|
16632
|
+
glossa [--label <name>] [directory]
|
|
16633
|
+
glossa status
|
|
15461
16634
|
glossa devices revoke <id>
|
|
15462
|
-
glossa
|
|
15463
|
-
glossa update
|
|
15464
|
-
glossa login
|
|
15465
|
-
glossa logout [--browser]
|
|
15466
|
-
glossa --version
|
|
16635
|
+
glossa logout
|
|
15467
16636
|
glossa --help
|
|
16637
|
+
glossa --version
|
|
15468
16638
|
|
|
15469
|
-
|
|
15470
|
-
ui: `Usage: glossa ui [directory] [--allow-broad-root] [--device-name <name>]
|
|
15471
|
-
|
|
15472
|
-
Opens an experimental compact session HUD for the current workspace.
|
|
15473
|
-
It starts immediately, shows connection and activity, and exits with q or Ctrl+C. --device-name names this computer on first enrollment.`,
|
|
15474
|
-
start: `Usage: glossa start [directory] [--allow-broad-root] [--device-name <name>]
|
|
15475
|
-
|
|
15476
|
-
Starts a foreground worker in the selected directory. Pass . to select the current directory explicitly.
|
|
15477
|
-
--device-name names this computer the first time it enrolls; once enrolled the name is reused. Press Ctrl+C to disconnect.`,
|
|
15478
|
-
status: `Usage: glossa status [--json]
|
|
15479
|
-
|
|
15480
|
-
Validates Google login, contacts the relay, and reports enrolled devices and active workers.`,
|
|
15481
|
-
doctor: `Usage: glossa doctor [--json]
|
|
15482
|
-
|
|
15483
|
-
Checks the runtime, relay and worker reachability, and read-only sign-in state, then reports whether Glossa is ready to start.`,
|
|
15484
|
-
devices: `Usage:
|
|
15485
|
-
glossa devices list [--json]
|
|
15486
|
-
glossa devices rename <id> <name>
|
|
15487
|
-
glossa devices revoke <id>
|
|
15488
|
-
|
|
15489
|
-
Lists, renames, or revokes computers enrolled with the current Google account.`,
|
|
15490
|
-
completions: `Usage: glossa completions <shell>
|
|
15491
|
-
|
|
15492
|
-
Prints a completion script for powershell, bash, zsh, or fish. Source it from your shell profile, for example: glossa completions powershell | Out-String | Invoke-Expression.`,
|
|
15493
|
-
update: `Usage: glossa update
|
|
15494
|
-
|
|
15495
|
-
Updates Glossa using the same installation method. glossa upgrade is an alias.`,
|
|
15496
|
-
login: `Usage: glossa login
|
|
15497
|
-
|
|
15498
|
-
Ensures the CLI has a valid Google session. Starting Glossa also signs in automatically.`,
|
|
15499
|
-
logout: `Usage: glossa logout [--browser]
|
|
16639
|
+
Running glossa opens one workspace in an interactive terminal.
|
|
15500
16640
|
|
|
15501
|
-
|
|
15502
|
-
|
|
16641
|
+
Keys:
|
|
16642
|
+
d recent activity
|
|
16643
|
+
s account and devices
|
|
16644
|
+
r revoke a device from status
|
|
16645
|
+
l sign out
|
|
16646
|
+
? help
|
|
16647
|
+
q disconnect and quit`;
|
|
15503
16648
|
async function withLoginSignal(action) {
|
|
15504
16649
|
const controller = new AbortController();
|
|
15505
16650
|
const cancel = () => controller.abort();
|
|
@@ -15510,124 +16655,92 @@ async function withLoginSignal(action) {
|
|
|
15510
16655
|
process.removeListener("SIGINT", cancel);
|
|
15511
16656
|
}
|
|
15512
16657
|
}
|
|
15513
|
-
async function
|
|
15514
|
-
|
|
15515
|
-
|
|
15516
|
-
|
|
15517
|
-
|
|
15518
|
-
if (!loaded) throw new Error("Glossa could not load the completed login.");
|
|
15519
|
-
return {
|
|
15520
|
-
credentials: await validCredentials(
|
|
15521
|
-
loaded.credentials,
|
|
15522
|
-
signal ? { signal } : {}
|
|
15523
|
-
),
|
|
15524
|
-
loginPerformed
|
|
15525
|
-
};
|
|
16658
|
+
async function authenticatedSession(signal) {
|
|
16659
|
+
if (signal) return await signedInSession({ ...loadAuthConfig(), signal });
|
|
16660
|
+
return await withLoginSignal(
|
|
16661
|
+
async (loginSignal) => await signedInSession({ ...loadAuthConfig(), signal: loginSignal })
|
|
16662
|
+
);
|
|
15526
16663
|
}
|
|
15527
|
-
async function
|
|
15528
|
-
|
|
15529
|
-
await authenticatedCredentials();
|
|
15530
|
-
await runManagedSession(root, loadRelayEndpoints(), allowBroadRoot, {
|
|
15531
|
-
...deviceName ? { deviceName } : {}
|
|
15532
|
-
});
|
|
16664
|
+
async function authenticatedCredentials(signal) {
|
|
16665
|
+
return (await authenticatedSession(signal)).credentials;
|
|
15533
16666
|
}
|
|
15534
|
-
async function showStatus(
|
|
15535
|
-
const
|
|
15536
|
-
const { credentials, profile } = await loadUserProfile(initial);
|
|
16667
|
+
async function showStatus() {
|
|
16668
|
+
const credentials = await authenticatedCredentials();
|
|
15537
16669
|
const endpoints = loadRelayEndpoints();
|
|
15538
|
-
const
|
|
15539
|
-
|
|
15540
|
-
|
|
15541
|
-
|
|
15542
|
-
const
|
|
15543
|
-
account,
|
|
15544
|
-
relay: endpoints.relayOrigin,
|
|
15545
|
-
connected: true,
|
|
15546
|
-
activeWorkers,
|
|
15547
|
-
devices
|
|
15548
|
-
};
|
|
15549
|
-
if (json2) {
|
|
15550
|
-
console.log(JSON.stringify(result, null, 2));
|
|
15551
|
-
return;
|
|
15552
|
-
}
|
|
15553
|
-
console.log(`Signed in as ${account}.`);
|
|
15554
|
-
console.log(`Relay connected: ${endpoints.relayOrigin}`);
|
|
15555
|
-
console.log(
|
|
15556
|
-
activeWorkers === null ? "Active workers: unavailable until the relay is updated" : `Active workers: ${activeWorkers}`
|
|
15557
|
-
);
|
|
15558
|
-
if (devices.length === 0) {
|
|
15559
|
-
console.log("No devices enrolled. Run glossa start in a workspace.");
|
|
15560
|
-
return;
|
|
15561
|
-
}
|
|
15562
|
-
const hint = noActiveWorkerHint(activeWorkers, devices.length);
|
|
15563
|
-
if (hint) console.log(hint);
|
|
15564
|
-
for (const device of devices) {
|
|
15565
|
-
console.log(formatDeviceRow(device));
|
|
15566
|
-
}
|
|
16670
|
+
const status = await new WorkspaceStatusService(
|
|
16671
|
+
credentials,
|
|
16672
|
+
endpoints
|
|
16673
|
+
).refresh();
|
|
16674
|
+
for (const line of formatStatus(status)) console.log(line);
|
|
15567
16675
|
}
|
|
15568
|
-
|
|
16676
|
+
function hudStatus(status) {
|
|
15569
16677
|
return {
|
|
15570
|
-
|
|
15571
|
-
|
|
15572
|
-
|
|
15573
|
-
|
|
15574
|
-
|
|
15575
|
-
|
|
15576
|
-
|
|
15577
|
-
|
|
15578
|
-
|
|
15579
|
-
|
|
15580
|
-
|
|
15581
|
-
|
|
15582
|
-
|
|
15583
|
-
|
|
16678
|
+
...status,
|
|
16679
|
+
devices: status.devices.map((device) => ({
|
|
16680
|
+
id: device.id,
|
|
16681
|
+
name: device.name,
|
|
16682
|
+
platform: device.platform ?? "Unknown platform",
|
|
16683
|
+
lastSeen: formatRelativeTime(device.lastSeenAt),
|
|
16684
|
+
status: deviceStatus(device)
|
|
16685
|
+
}))
|
|
16686
|
+
};
|
|
16687
|
+
}
|
|
16688
|
+
async function revokeKnownDevice(deviceId) {
|
|
16689
|
+
const credentials = await authenticatedCredentials();
|
|
16690
|
+
await revokeDevice(loadRelayEndpoints(), credentials, deviceId);
|
|
16691
|
+
}
|
|
16692
|
+
async function runWorkspace(path8, label) {
|
|
16693
|
+
const root = await selectExposureRoot(path8);
|
|
16694
|
+
const endpoints = loadRelayEndpoints();
|
|
16695
|
+
let credentials = (await authenticatedSession()).credentials;
|
|
16696
|
+
const statusService = new WorkspaceStatusService(credentials, endpoints);
|
|
16697
|
+
let postExitNotice;
|
|
16698
|
+
const exitAction = await runSessionHud({
|
|
15584
16699
|
workspace: root,
|
|
15585
16700
|
run: async (signal, onEvent) => {
|
|
15586
|
-
await
|
|
15587
|
-
|
|
16701
|
+
await runManagedSession(root, endpoints, {
|
|
16702
|
+
credentials,
|
|
16703
|
+
...label ? { workspaceLabel: label } : {},
|
|
15588
16704
|
signal,
|
|
15589
|
-
onEvent
|
|
16705
|
+
onEvent: (event) => {
|
|
16706
|
+
postExitNotice = retainPostExitNotice(postExitNotice, event);
|
|
16707
|
+
onEvent(event);
|
|
16708
|
+
},
|
|
15590
16709
|
quiet: true,
|
|
15591
|
-
handleProcessSignals: false
|
|
15592
|
-
...deviceName ? { deviceName } : {}
|
|
16710
|
+
handleProcessSignals: false
|
|
15593
16711
|
});
|
|
16712
|
+
},
|
|
16713
|
+
loadStatus: async (signal) => {
|
|
16714
|
+
return hudStatus(await statusService.refresh(signal));
|
|
16715
|
+
},
|
|
16716
|
+
revokeDevice: async (deviceId, signal) => {
|
|
16717
|
+
credentials = await validCredentials(credentials, { signal });
|
|
16718
|
+
await revokeDevice(
|
|
16719
|
+
endpoints,
|
|
16720
|
+
credentials,
|
|
16721
|
+
deviceId,
|
|
16722
|
+
async (input, init) => await fetch(input, { ...init, signal })
|
|
16723
|
+
);
|
|
15594
16724
|
}
|
|
15595
16725
|
});
|
|
16726
|
+
if (postExitNotice) console.error(postExitNotice);
|
|
16727
|
+
if (exitAction === "logout") await logoutFromGlossa();
|
|
15596
16728
|
}
|
|
15597
16729
|
async function main() {
|
|
15598
16730
|
const invocation = parseInvocation(process.argv.slice(2));
|
|
15599
16731
|
if (invocation.command === "help") {
|
|
15600
|
-
console.log(
|
|
16732
|
+
console.log(HELP);
|
|
15601
16733
|
} else if (invocation.command === "version") {
|
|
15602
16734
|
console.log(VERSION);
|
|
15603
|
-
} else if (invocation.command === "
|
|
15604
|
-
await
|
|
15605
|
-
} else if (invocation.command === "start") {
|
|
15606
|
-
await runExposure(invocation.path, invocation.allowBroadRoot, invocation.deviceName);
|
|
16735
|
+
} else if (invocation.command === "workspace") {
|
|
16736
|
+
await runWorkspace(invocation.path, invocation.label);
|
|
15607
16737
|
} else if (invocation.command === "status") {
|
|
15608
|
-
await showStatus(
|
|
15609
|
-
} else if (invocation.command === "doctor") {
|
|
15610
|
-
const ok = await runDoctor(invocation.json);
|
|
15611
|
-
if (!ok) process.exitCode = 1;
|
|
15612
|
-
} else if (invocation.command === "login") {
|
|
15613
|
-
const { loginPerformed } = await authenticatedCredentials();
|
|
15614
|
-
if (!loginPerformed) console.log("Signed in to Glossa.");
|
|
16738
|
+
await showStatus();
|
|
15615
16739
|
} else if (invocation.command === "logout") {
|
|
15616
|
-
await logoutFromGlossa(
|
|
15617
|
-
} else if (invocation.command === "completions") {
|
|
15618
|
-
console.log(completionScript(invocation.shell));
|
|
15619
|
-
} else if (invocation.command === "update") {
|
|
15620
|
-
await updateGlossa({ currentVersion: VERSION });
|
|
15621
|
-
} else if (invocation.action === "list") {
|
|
15622
|
-
await showDevices(invocation.json);
|
|
15623
|
-
} else if (invocation.action === "rename") {
|
|
15624
|
-
const { endpoints, credentials } = await deviceCredentials();
|
|
15625
|
-
const device = await renameDevice(endpoints, credentials, invocation.deviceId, invocation.name);
|
|
15626
|
-
console.log(`Renamed device ${device.id} to ${device.name}.`);
|
|
16740
|
+
await logoutFromGlossa();
|
|
15627
16741
|
} else {
|
|
15628
|
-
|
|
15629
|
-
|
|
15630
|
-
console.log(`Revoked device ${invocation.deviceId}. Running workers on it are disconnected.`);
|
|
16742
|
+
await revokeKnownDevice(invocation.deviceId);
|
|
16743
|
+
console.log(`Revoked device ${invocation.deviceId}. Running workspaces on it are disconnected.`);
|
|
15631
16744
|
}
|
|
15632
16745
|
}
|
|
15633
16746
|
main().catch((error46) => {
|