aegis-desktop 1.0.13 → 1.0.15

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.
Files changed (96) hide show
  1. package/SOURCE-OFFER.txt +9 -0
  2. package/bin/aegis.js +1 -32
  3. package/lib/account-key.js +1 -56
  4. package/lib/auto.js +1 -430
  5. package/lib/avatar/companion.js +1 -698
  6. package/lib/avatar/cosmetics.js +1 -532
  7. package/lib/avatar/events.js +1 -344
  8. package/lib/avatar/holder-turn.js +1 -350
  9. package/lib/avatar/holders.js +1 -612
  10. package/lib/avatar/identity.js +1 -641
  11. package/lib/avatar/level.js +1 -368
  12. package/lib/avatar/mirror.js +1 -524
  13. package/lib/avatar/persona.js +1 -388
  14. package/lib/avatar/profile.js +1 -977
  15. package/lib/avatar/register.js +1 -307
  16. package/lib/avatar/store.js +1 -472
  17. package/lib/avatar/tiers.js +1 -38
  18. package/lib/avatar/transfer.js +1 -415
  19. package/lib/avatar/turn.js +1 -216
  20. package/lib/avatar/voice.js +1 -328
  21. package/lib/avatar/xp.js +1 -404
  22. package/lib/deep-link.js +1 -120
  23. package/lib/fingerprint.js +2 -347
  24. package/lib/google-connect.js +1 -169
  25. package/lib/local/agents.js +1 -105
  26. package/lib/local/autonomous.js +1 -1045
  27. package/lib/local/claude-pro.js +1 -993
  28. package/lib/local/context.js +1 -95
  29. package/lib/local/engine.js +1 -3228
  30. package/lib/local/entitlement.js +1 -654
  31. package/lib/local/git-scope.js +1 -228
  32. package/lib/local/local.js +1 -2135
  33. package/lib/local/prompt.js +1 -275
  34. package/lib/local/queue.js +1 -560
  35. package/lib/local/session-rounds.js +1 -208
  36. package/lib/local/shell.js +1 -680
  37. package/lib/local/telemetry.js +1 -470
  38. package/lib/local/tools.js +1 -1238
  39. package/lib/local/turn-guard.js +1 -300
  40. package/lib/local/worktree-lock.js +1 -235
  41. package/lib/provider-key-mirror.js +1 -252
  42. package/lib/quick-launcher.js +1 -84
  43. package/lib/settings.js +1 -890
  44. package/lib/sync/memory-queue.js +1 -60
  45. package/lib/sync/persist-gate.js +1 -148
  46. package/lib/sync/sessions.js +1 -106
  47. package/lib/window-state.js +1 -66
  48. package/main.js +3 -4390
  49. package/package.json +3 -5
  50. package/preload.js +3 -628
  51. package/renderer/app.js +1 -6560
  52. package/renderer/avatar/assemble.js +1 -335
  53. package/renderer/avatar/avatar.js +1 -380
  54. package/renderer/avatar/cards.js +1 -141
  55. package/renderer/avatar/hud.js +1 -206
  56. package/renderer/avatar/machine.js +1 -213
  57. package/renderer/avatar/pane.js +1 -439
  58. package/renderer/avatar/parts.js +1 -437
  59. package/renderer/budget.js +1 -168
  60. package/renderer/companion.js +1 -223
  61. package/renderer/diff.js +1 -359
  62. package/renderer/diffview.js +1 -336
  63. package/renderer/fingerprint.js +3 -720
  64. package/renderer/markdown.js +1 -168
  65. package/renderer/provider-toolbar.js +1 -134
  66. package/renderer/providers.js +1 -259
  67. package/renderer/quick.js +1 -191
  68. package/renderer/stream-policy.js +1 -306
  69. package/renderer/transcript-view.js +1 -323
  70. package/renderer/usage.js +1 -1239
  71. package/vendor/aegis.js +1 -2054
  72. package/vendor/attest.js +1 -442
  73. package/vendor/authorship-key.js +1 -410
  74. package/vendor/auto-provision.js +1 -134
  75. package/vendor/brain-catalog.js +1 -268
  76. package/vendor/byok-catalog.js +1 -230
  77. package/vendor/credentials.js +1 -494
  78. package/vendor/device-fingerprint.js +1 -141
  79. package/vendor/drive-connect.js +1 -85
  80. package/vendor/drive-upload.js +1 -174
  81. package/vendor/env-file.js +1 -1273
  82. package/vendor/fingerprint.js +1 -1064
  83. package/vendor/foreign-memory.js +1 -669
  84. package/vendor/google-signin.js +1 -125
  85. package/vendor/hooks.js +1 -430
  86. package/vendor/model-discovery.js +1 -330
  87. package/vendor/platform.js +1 -937
  88. package/vendor/prefs.js +1 -351
  89. package/vendor/presence-constants.js +1 -107
  90. package/vendor/presence.js +1 -438
  91. package/vendor/record-vault.js +3 -1055
  92. package/vendor/session-store.js +1 -548
  93. package/vendor/topup.js +1 -297
  94. package/vendor/touch.js +1 -458
  95. package/vendor/update.js +1 -704
  96. package/vendor/user-models.js +1 -381
package/vendor/update.js CHANGED
@@ -1,706 +1,3 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
- // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
- // Conveying a modified copy, or running one as a network service, owes the
5
- // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
- /**
7
- * "There is a newer version" — the notice this CLI never had.
8
- *
9
- * Checked on 2026-09-15: there was no update check anywhere in the client, so
10
- * a user learned about a release only by guessing to run
11
- * `npm i -g aegiscode@latest`. Publishing therefore reached almost nobody, and
12
- * the installed base drifted months behind the registry.
13
- *
14
- * Three rules, because an update check is a background nicety that must never
15
- * become a liability:
16
- * · it never blocks — the caller gets a cached answer immediately and the
17
- * network call settles whenever it settles;
18
- * · it never throws — offline, proxied, rate-limited or garbage JSON all
19
- * mean "no notice", not a broken CLI;
20
- * · it never nags — one check a day, cached, and nothing printed when the
21
- * user is already current.
22
- */
23
-
24
- const https = require('node:https');
25
-
26
- const REGISTRY = 'https://registry.npmjs.org';
27
- //: Default package. Both hosts ship from npm — the CLI as `aegiscode`, the
28
- //: desktop as `aegis-desktop` — so one checker serves both; the caller names
29
- //: which one it is.
30
- const PKG = 'aegiscode';
31
- /** One check a day. A CLI that pings the registry every launch is spyware. */
32
- const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
33
- /** The registry is not on the critical path; give up quickly and silently. */
34
- const TIMEOUT_MS = 2500;
35
-
36
- /** Compare two semver-ish strings. Returns true when `latest` is newer. */
37
- function isNewer(latest, current) {
38
- const parse = (v) =>
39
- String(v || '')
40
- .trim()
41
- .replace(/^v/, '')
42
- .split('-')[0] // a prerelease never counts as newer than its release
43
- .split('.')
44
- .map((n) => Number.parseInt(n, 10) || 0);
45
- const a = parse(latest);
46
- const b = parse(current);
47
- for (let i = 0; i < Math.max(a.length, b.length); i++) {
48
- const x = a[i] || 0;
49
- const y = b[i] || 0;
50
- if (x !== y) return x > y;
51
- }
52
- return false;
53
- }
54
-
55
- /**
56
- * Why a check produced no version. Every one of these used to collapse into a
57
- * bare `null`, which is enough for the CLI ("no notice") but NOT enough for a
58
- * host that shows the user a sentence about it: the desktop rendered all six
59
- * as "registry unreachable", so a 2.5s timeout on a slow VPN was reported as
60
- * fact about the registry — a false accusation, and unactionable.
61
- */
62
- const REASONS = Object.freeze({
63
- TIMEOUT: 'timeout',
64
- NETWORK: 'network',
65
- NOT_FOUND: 'not-found',
66
- FORBIDDEN: 'forbidden',
67
- HTTP: 'http',
68
- MALFORMED: 'malformed',
69
- TOO_LARGE: 'too-large',
70
- });
71
-
72
- /**
73
- * Does this failure mean "the network let us down" (worth retrying silently, and
74
- * NOT worth telling the user about) rather than "the registry answered, and the
75
- * answer was no" (a real condition the user may need to act on)?
76
- *
77
- * `status` is the HTTP status when there was one, so 5xx/429 count as transient
78
- * while 404/403 do not.
79
- */
80
- function isTransientReason(reason, status = null) {
81
- if (reason === REASONS.TIMEOUT || reason === REASONS.NETWORK) return true;
82
- if (reason === REASONS.HTTP) return !status || status >= 500 || status === 429;
83
- return false;
84
- }
85
-
86
- /**
87
- * Fetch the registry's `latest` dist-tag, saying WHY when it fails.
88
- *
89
- * Resolves `{ ok, latest, reason, status }` and never rejects: `ok` is the
90
- * version question, `reason` (one of REASONS) is the diagnosis. Use this when
91
- * you intend to tell someone what happened; use `fetchLatest` when you only
92
- * care whether there is a notice.
93
- */
94
- function fetchLatestDetailed({ pkg = PKG, timeoutMs = TIMEOUT_MS } = {}) {
95
- return new Promise((resolve) => {
96
- let settled = false;
97
- const done = (v) => {
98
- if (!settled) {
99
- settled = true;
100
- resolve(v);
101
- }
102
- };
103
- const fail = (reason, status = null) =>
104
- done({ ok: false, latest: null, reason, status });
105
- const succeed = (latest) => done({ ok: true, latest, reason: null, status: 200 });
106
- try {
107
- // The abbreviated metadata document: a few KB instead of the full
108
- // packument, which for a package with this many releases is megabytes.
109
- const req = https.get(
110
- `${REGISTRY}/${pkg}`,
111
- { headers: { accept: 'application/vnd.npm.install-v1+json' }, timeout: timeoutMs },
112
- (res) => {
113
- const status = res.statusCode;
114
- if (status !== 200) {
115
- res.resume();
116
- if (status === 404) return fail(REASONS.NOT_FOUND, status);
117
- if (status === 401 || status === 403) return fail(REASONS.FORBIDDEN, status);
118
- return fail(REASONS.HTTP, status);
119
- }
120
- let body = '';
121
- res.setEncoding('utf8');
122
- res.on('data', (c) => {
123
- body += c;
124
- if (body.length > 2_000_000) {
125
- req.destroy();
126
- fail(REASONS.TOO_LARGE, status);
127
- }
128
- });
129
- res.on('end', () => {
130
- try {
131
- const tags = JSON.parse(body)['dist-tags'];
132
- const latest = (tags && tags.latest) || null;
133
- // A 200 that parses but names no `latest` is a malformed
134
- // document, not a slow network — do not invite a retry for it.
135
- if (!latest) return fail(REASONS.MALFORMED, status);
136
- succeed(latest);
137
- } catch {
138
- fail(REASONS.MALFORMED, status);
139
- }
140
- });
141
- }
142
- );
143
- req.on('timeout', () => {
144
- req.destroy();
145
- fail(REASONS.TIMEOUT);
146
- });
147
- // Every socket-level failure — DNS (ENOTFOUND), refused (ECONNREFUSED),
148
- // TLS, proxy, reset mid-body — is the same story to a user: the network.
149
- req.on('error', () => fail(REASONS.NETWORK));
150
- } catch {
151
- fail(REASONS.NETWORK);
152
- }
153
- });
154
- }
155
-
156
- /** Fetch the registry's `latest` dist-tag. Resolves null on any failure. */
157
- function fetchLatest(opts = {}) {
158
- return fetchLatestDetailed(opts).then((r) => (r.ok ? r.latest : null));
159
- }
160
-
161
- //: Where the AEGIS server answers "am I still allowed to talk to you".
162
- const DEFAULT_API_BASE = 'https://aegiscloud.org';
163
- /** /api/v1/version is a tiny JSON document; anything bigger is a proxy page. */
164
- const VERSION_BODY_CAP = 64 * 1024;
165
-
166
- /**
167
- * Ask the SERVER whether this client is still serviceable.
168
- *
169
- * Why this exists next to the npm check: they answer different questions.
170
- * The registry says what was *published*; the server says what it will
171
- * *accept*. aegis1 refuses every request from a client below
172
- * `MIN_CLIENT_VERSION` with a 426, so a client that only asks npm learns it is
173
- * unservicable by being refused mid-task — an outage, not a notice. Asking
174
- * `/api/v1/version` first turns the same fact into a sentence the user reads
175
- * before they hit the wall.
176
- *
177
- * `/api/v1/version` is informational: it never refuses anyone, and it echoes
178
- * the verdict for the version header we send (`upgrade_required`).
179
- *
180
- * Resolves `{ ok, minClient, latestCli, latestDesktop, upgradeRequired,
181
- * downloadUrl, reason, status }` and never rejects — same contract as
182
- * `fetchLatestDetailed`, so a host can render "registry unreachable" and
183
- * "server unreachable" differently instead of inventing a third story.
184
- */
185
- function fetchServerVersionDetailed({
186
- base = DEFAULT_API_BASE,
187
- clientVersion = null,
188
- timeoutMs = TIMEOUT_MS,
189
- getImpl = null,
190
- } = {}) {
191
- return new Promise((resolve) => {
192
- let settled = false;
193
- const done = (v) => {
194
- if (!settled) {
195
- settled = true;
196
- resolve(v);
197
- }
198
- };
199
- const fail = (reason, status = null) =>
200
- done({
201
- ok: false, minClient: null, latestCli: null, latestDesktop: null,
202
- upgradeRequired: null, downloadUrl: '', reason, status,
203
- });
204
- const succeed = (body, status) => {
205
- const minClient = body && typeof body.min_client === 'string' ? body.min_client : null;
206
- // A 200 without `min_client` is malformed, not a slow network: do not
207
- // invite a retry ladder for a document that will parse the same way.
208
- if (!minClient) return fail(REASONS.MALFORMED, status);
209
- done({
210
- ok: true,
211
- minClient,
212
- latestCli: typeof body.latest_cli === 'string' ? body.latest_cli : '',
213
- latestDesktop: typeof body.latest_desktop === 'string' ? body.latest_desktop : '',
214
- // Trust the header echo only when we actually sent a version: without
215
- // one the server answers for `require_header=False` and the answer is
216
- // about nothing in particular. `null` here means "the server did not
217
- // say", which is different from "the server said no" — the caller must
218
- // be able to fall back without overriding an answer it was given.
219
- upgradeRequired:
220
- clientVersion && typeof body.upgrade_required === 'boolean'
221
- ? body.upgrade_required
222
- : null,
223
- downloadUrl: typeof body.download_url === 'string' ? body.download_url : '',
224
- reason: null,
225
- status,
226
- });
227
- };
228
-
229
- try {
230
- const url = `${String(base || DEFAULT_API_BASE).replace(/\/+$/, '')}/api/v1/version`;
231
- const headers = { accept: 'application/json' };
232
- if (clientVersion) headers['X-AEGIS-Version'] = String(clientVersion);
233
-
234
- const onResponse = (res) => {
235
- const status = res.statusCode;
236
- if (status !== 200) {
237
- res.resume();
238
- if (status === 404) return fail(REASONS.NOT_FOUND, status);
239
- if (status === 401 || status === 403) return fail(REASONS.FORBIDDEN, status);
240
- // 426 here would mean the endpoint itself were gated; treat it as a
241
- // real answer rather than a transport failure if it ever happens.
242
- if (status === 426) return fail(REASONS.HTTP, status);
243
- return fail(REASONS.HTTP, status);
244
- }
245
- let body = '';
246
- res.setEncoding('utf8');
247
- res.on('data', (c) => {
248
- body += c;
249
- if (body.length > VERSION_BODY_CAP) {
250
- res.destroy();
251
- fail(REASONS.TOO_LARGE, status);
252
- }
253
- });
254
- res.on('end', () => {
255
- if (settled) return;
256
- try {
257
- succeed(JSON.parse(body), status);
258
- } catch {
259
- fail(REASONS.MALFORMED, status);
260
- }
261
- });
262
- };
263
-
264
- if (typeof getImpl === 'function') {
265
- // Injected transport for tests and hosts that already own a fetch:
266
- // `getImpl({url, headers, timeoutMs})` resolves `{status, text}`.
267
- Promise.resolve(getImpl({ url, headers, timeoutMs }))
268
- .then((r) => {
269
- const status = r && r.status != null ? r.status : 0;
270
- if (!status) return fail(REASONS.NETWORK);
271
- if (status !== 200) {
272
- if (status === 404) return fail(REASONS.NOT_FOUND, status);
273
- if (status === 401 || status === 403) return fail(REASONS.FORBIDDEN, status);
274
- return fail(REASONS.HTTP, status);
275
- }
276
- const text = r && typeof r.text === 'string' ? r.text : '';
277
- if (text.length > VERSION_BODY_CAP) return fail(REASONS.TOO_LARGE, status);
278
- try {
279
- succeed(JSON.parse(text), status);
280
- } catch {
281
- fail(REASONS.MALFORMED, status);
282
- }
283
- })
284
- .catch(() => fail(REASONS.NETWORK));
285
- return;
286
- }
287
-
288
- const req = require(/^http:\/\//i.test(url) ? 'node:http' : 'node:https')
289
- .get(url, { headers, timeout: timeoutMs }, onResponse);
290
- req.on('timeout', () => {
291
- req.destroy();
292
- fail(REASONS.TIMEOUT);
293
- });
294
- req.on('error', () => fail(REASONS.NETWORK));
295
- } catch {
296
- fail(REASONS.NETWORK);
297
- }
298
- });
299
- }
300
-
301
- /** The server's minimum client version, or null. */
302
- function fetchServerVersion(opts = {}) {
303
- return fetchServerVersionDetailed(opts).then((r) => (r.ok ? r.minClient : null));
304
- }
305
-
306
- /**
307
- * The server's verdict, expressed in the shape the notice/plan code already
308
- * speaks: `{ behind, latest, required, downloadUrl, ok, reason, status }`.
309
- *
310
- * `behind` is true when the SERVER says this client is below its minimum —
311
- * regardless of whether npm happens to have something newer. That ordering is
312
- * deliberate: being refused is actionable now, being merely superseded is not.
313
- */
314
- async function serverUpdateVerdict({
315
- current,
316
- base = DEFAULT_API_BASE,
317
- timeoutMs = TIMEOUT_MS,
318
- getImpl = null,
319
- } = {}) {
320
- const res = await fetchServerVersionDetailed({ base, clientVersion: current, timeoutMs, getImpl });
321
- if (!res.ok) {
322
- return { ok: false, behind: false, latest: null, required: null, downloadUrl: '', reason: res.reason, status: res.status };
323
- }
324
- const required = res.minClient || null;
325
- // The server's echo decides. Falling back to local arithmetic only when it
326
- // said nothing (`null`) — OR'ing the two would let our own comparison
327
- // override the very authority we asked, which is the bug this line exists to
328
- // not have.
329
- const behind =
330
- res.upgradeRequired === true
331
- ? true
332
- : res.upgradeRequired === false
333
- ? false
334
- : required
335
- ? isNewer(required, current)
336
- : false;
337
- return {
338
- ok: true,
339
- behind,
340
- // The server's own minimum is the target when it is the thing refusing us;
341
- // a published version is preferred when it names one, because that is what
342
- // the user should actually install.
343
- latest: (behind && (res.latestCli || res.latestDesktop)) || required,
344
- latestCli: res.latestCli,
345
- latestDesktop: res.latestDesktop,
346
- required,
347
- downloadUrl: res.downloadUrl,
348
- reason: null,
349
- status: res.status,
350
- };
351
- }
352
-
353
- /**
354
- * The notice to show, from cache. Never waits on the network.
355
- *
356
- * `cache` is the persisted `{ checkedAt, latest }` blob; `save` persists a new
357
- * one. Returns `{ latest, behind }`, and kicks off a refresh in the background
358
- * when the cache is stale.
359
- */
360
- function updateNotice({
361
- current,
362
- cache,
363
- save,
364
- now = Date.now(),
365
- fetchImpl = fetchLatest,
366
- intervalMs = CHECK_INTERVAL_MS,
367
- } = {}) {
368
- const c = cache || {};
369
- const fresh = typeof c.checkedAt === 'number' && now - c.checkedAt < intervalMs;
370
-
371
- if (!fresh) {
372
- // Fire and forget: this run reports on what was already known, and the
373
- // answer lands for the next one. A first run therefore never shows a
374
- // notice, which is correct — it has nothing to compare against yet.
375
- Promise.resolve(fetchImpl())
376
- .then((latest) => {
377
- if (latest && typeof save === 'function') save({ checkedAt: now, latest });
378
- })
379
- .catch(() => {});
380
- }
381
-
382
- const latest = c.latest || null;
383
- return { latest, behind: !!(latest && isNewer(latest, current)) };
384
- }
385
-
386
- /**
387
- * "What happens next", from a check result — the ONE verdict every applier
388
- * consumes (AEGISONE, decision D7).
389
- *
390
- * There are two *mechanics* — `npm i -g` for the CLI's channel and
391
- * `electron-updater` for the window's — and that is deliberate: they install
392
- * different things. What must not be duplicated is the *decision*: whether
393
- * there is anything to do, which version it is, which applier owns it and what
394
- * the user is told to run. Both hosts ask this function, so the sentence a
395
- * user reads in the terminal and the sentence the window shows cannot drift.
396
- *
397
- * Pure: no fetch, no clock, no cache. Feed it a result from `checkPublished`
398
- * (or from the cached `updateNotice`) and it names the next action.
399
- *
400
- * @param {{current: string, latest: ?string, behind: boolean, channel?: 'npm'|'desktop', pkg?: string}} input
401
- */
402
- function updatePlan({ current, latest, behind, channel = 'npm', pkg = PKG, method = 'npm' } = {}) {
403
- const currentText = String(current == null ? '' : current);
404
- const latestText = latest ? String(latest) : null;
405
- const pending = Boolean(behind) && Boolean(latestText);
406
- if (!pending) {
407
- return {
408
- channel,
409
- method,
410
- current: currentText,
411
- latest: latestText,
412
- behind: false,
413
- action: 'none',
414
- applier: 'none',
415
- instruction: null,
416
- apply: null,
417
- };
418
- }
419
- if (channel === 'desktop') {
420
- // The window installs its own build: there is nothing for the user to type,
421
- // and offering `npm i -g aegis-desktop` here would install a *second* copy
422
- // beside the running app. The applier is the app's own updater.
423
- return {
424
- channel,
425
- method,
426
- current: currentText,
427
- latest: latestText,
428
- behind: true,
429
- action: 'desktop-update',
430
- applier: 'electron-updater',
431
- instruction: 'Aegis Desktop → Check for Updates…',
432
- apply: null,
433
- };
434
- }
435
-
436
- // Which installer owns this copy. The command is not cosmetic: `npm i -g`
437
- // against a pip-installed `aegiscode` writes a second copy beside the one the
438
- // user actually runs, and the "upgrade" appears to do nothing. Same for an
439
- // `npx` run, which has nothing installed to upgrade at all.
440
- const applier = APPLIERS[method] || APPLIERS.npm;
441
- return {
442
- channel,
443
- method,
444
- current: currentText,
445
- latest: latestText,
446
- behind: true,
447
- action: applier.action,
448
- applier: applier.name,
449
- instruction: applier.instruction(pkg),
450
- apply: applier.apply ? applier.apply(pkg) : null,
451
- };
452
- }
453
-
454
- /**
455
- * The ways this product reaches a machine, and what "update it" means for each.
456
- *
457
- * `apply` names argv to run — it is what the window's Update button and the
458
- * CLI's `/upgrade apply` execute. `null` means there is nothing to install:
459
- * `npx` fetches `latest` on every launch by definition, and a source checkout
460
- * belongs to its owner's git remote.
461
- */
462
- const APPLIERS = Object.freeze({
463
- npm: Object.freeze({
464
- name: 'npm',
465
- action: 'npm-update',
466
- instruction: (pkg) => `npm i -g ${pkg}@latest`,
467
- apply: (pkg) => ({ command: 'npm', args: ['i', '-g', `${pkg}@latest`] }),
468
- }),
469
- pip: Object.freeze({
470
- name: 'pip',
471
- action: 'pip-update',
472
- // The published pip package is a launcher for the npm one, so this is
473
- // genuinely a different command — not an npm call with a different word.
474
- instruction: (pkg) => `pip install -U ${pkg}`,
475
- apply: (pkg) => ({ command: process.platform === 'win32' ? 'python' : 'python3',
476
- args: ['-m', 'pip', 'install', '-U', pkg] }),
477
- }),
478
- npx: Object.freeze({
479
- name: 'none',
480
- action: 'npx-update',
481
- instruction: () => 'already fetched fresh on every run — restart to pick up the new version',
482
- apply: null,
483
- }),
484
- source: Object.freeze({
485
- name: 'git',
486
- action: 'source-update',
487
- instruction: () => 'git pull && npm ci (this copy is a source checkout)',
488
- apply: null,
489
- }),
490
- });
491
-
492
- /**
493
- * Which installer owns the copy that is running — so the sentence names a
494
- * command that actually changes THIS binary.
495
- *
496
- * Order matters. An explicit declaration wins (the pip launcher sets
497
- * `AEGIS_INSTALL_METHOD=pip` before exec'ing the real CLI, which is the only
498
- * way the npm package can know a Python install is driving it). Then npx, which
499
- * is detectable from the argv it builds. Then a source checkout, then npm.
500
- *
501
- * Pure and injectable: `env`, `argv` and a file probe, no `process` reads, so a
502
- * test can assert every branch without npm or pip being installed.
503
- */
504
- function detectInstallMethod({
505
- env = {}, argv = [], pkg = PKG, fileExists = () => false, entry = '',
506
- } = {}) {
507
- const declared = String((env && env.AEGIS_INSTALL_METHOD) || '').trim().toLowerCase();
508
- if (declared && Object.prototype.hasOwnProperty.call(APPLIERS, declared)) return declared;
509
-
510
- const joined = argv.map((a) => String(a)).join(' ');
511
- if (/\bnpx\b/.test(joined) || /_npx[\\/]/.test(joined) || /_npx[\\/]/.test(entry)) return 'npx';
512
- if (/[\\/]pipx[\\/]venvs[\\/]/.test(entry) || /[\\/]site-packages[\\/]/.test(entry)) return 'pip';
513
- if (/[\\/]node_modules[\\/]/.test(entry)) return 'npm';
514
- // A git checkout has the repo's own marker files; a global npm install does
515
- // not (they are not in the package's `files` list).
516
- if (fileExists('.git') && fileExists('client/update.js')) return 'source';
517
- return 'npm';
518
- }
519
-
520
- /**
521
- * The same once-a-day check as `updateNotice`, but *awaited* and detailed.
522
- *
523
- * Why both exist: `updateNotice` answers immediately from cache and lets the
524
- * refresh land later, which is right for a CLI that must never block a launch.
525
- * A host that is *showing the user a banner about it* — the window, or
526
- * `aegis doctor` — needs the full result (`.ok`, `.reason`, `.status`) and is
527
- * willing to wait for it. So this is not a second check: it is the same probe
528
- * (`fetchLatestDetailed`, same `TIMEOUT_MS`, same `REASONS` taxonomy) and the
529
- * same `CHECK_INTERVAL_MS` cache rule, extracted so that the desktop applier
530
- * stops carrying its own copy of the fetching logic (D7: the desktop keeps its
531
- * applier and loses its version fetch).
532
- *
533
- * Never throws: a failure is a result with `ok: false` and a reason.
534
- *
535
- * @returns {Promise<{ok: boolean, latest: ?string, behind: boolean, reason: ?string,
536
- * status: ?number, cached: boolean, checkedAt: ?number}>}
537
- */
538
- async function checkPublished({
539
- current,
540
- cache,
541
- save,
542
- now = Date.now(),
543
- fetchImpl = fetchLatestDetailed,
544
- intervalMs = CHECK_INTERVAL_MS,
545
- timeoutMs = TIMEOUT_MS,
546
- pkg = PKG,
547
- } = {}) {
548
- const c = cache || null;
549
- const fresh = Boolean(c) && typeof c.checkedAt === 'number' && now - c.checkedAt < intervalMs;
550
- if (fresh) {
551
- const latest = c.latest || null;
552
- return {
553
- ok: Boolean(latest),
554
- latest,
555
- behind: !!(latest && isNewer(latest, current)),
556
- reason: latest ? null : c.reason || null,
557
- status: latest ? 200 : c.status == null ? null : c.status,
558
- cached: true,
559
- checkedAt: c.checkedAt,
560
- };
561
- }
562
-
563
- let res;
564
- try {
565
- res = await fetchImpl({ pkg, timeoutMs });
566
- } catch (err) {
567
- res = { ok: false, latest: null, reason: REASONS.NETWORK, status: null, error: String((err && err.message) || err) };
568
- }
569
- const record = {
570
- checkedAt: now,
571
- latest: res && res.latest ? res.latest : null,
572
- reason: (res && res.reason) || null,
573
- status: res && res.status != null ? res.status : null,
574
- };
575
- if (typeof save === 'function') {
576
- try {
577
- save(record);
578
- } catch {
579
- /* failing to persist a notice is never worth failing the call */
580
- }
581
- }
582
- return {
583
- ok: Boolean(res && res.ok && res.latest),
584
- latest: record.latest,
585
- behind: !!(record.latest && isNewer(record.latest, current)),
586
- reason: record.reason,
587
- status: record.status,
588
- cached: false,
589
- checkedAt: now,
590
- };
591
- }
592
-
593
- /**
594
- * One line for the welcome box, or null when there is nothing to say.
595
- *
596
- * Rendered from `updatePlan`, not from a second copy of the sentence: the
597
- * command the terminal tells the user to run and the command the window's
598
- * banner offers are the same string produced by the same verdict.
599
- *
600
- * `method` is the install channel the caller detected (see
601
- * `detectInstallMethod`). It is threaded through rather than assumed to be npm
602
- * because the previous version returned `null` — silence — for every channel
603
- * that was not npm, so a pip install and an npx run were never told a release
604
- * existed. A host that knows how it was installed should say so.
605
- */
606
- function updateLine({ current, latest, behind, pkg = PKG, method = 'npm' }) {
607
- const plan = updatePlan({ current, latest, behind, channel: 'npm', pkg, method });
608
- if (!plan.behind) return null;
609
- const head = `Update available: ${plan.current} → ${plan.latest}`;
610
- // `apply` is null for the two channels with nothing to install, whose
611
- // instruction is already a sentence — prefixing those with "run:" would read
612
- // as a command that does not exist.
613
- return plan.apply
614
- ? `${head} run: ${plan.instruction}`
615
- : `${head} ${plan.instruction}`;
616
- }
617
-
618
- /**
619
- * The server's verdict, from cache. Never waits on the network.
620
- *
621
- * Same contract and the same once-a-day rule as `updateNotice`, for the same
622
- * reason: the welcome box draws from what the last run learned, and the
623
- * refresh lands for the next one. A first run shows nothing, which is right —
624
- * it has no verdict yet.
625
- *
626
- * Returns `{ behind, latest, required, downloadUrl }`. `behind` is the server's
627
- * answer, not a comparison this module invented, so the box says "update" only
628
- * when the server would actually refuse this client.
629
- */
630
- function serverNotice({
631
- current,
632
- cache,
633
- save,
634
- now = Date.now(),
635
- verdictImpl = serverUpdateVerdict,
636
- intervalMs = CHECK_INTERVAL_MS,
637
- } = {}) {
638
- const c = cache || {};
639
- const fresh = typeof c.checkedAt === 'number' && now - c.checkedAt < intervalMs;
640
-
641
- if (!fresh) {
642
- Promise.resolve()
643
- .then(() => verdictImpl({ current }))
644
- .then((v) => {
645
- if (!v || typeof save !== 'function') return;
646
- // Persist the verdict even when it is "not behind": that is what tells
647
- // the next run it already asked today, and stops a check per launch.
648
- save({
649
- checkedAt: now,
650
- behind: Boolean(v.behind),
651
- required: v.required || null,
652
- latest: v.latest || null,
653
- downloadUrl: v.downloadUrl || '',
654
- reason: v.reason || null,
655
- });
656
- })
657
- .catch(() => {});
658
- }
659
-
660
- return {
661
- behind: Boolean(c.behind),
662
- latest: c.latest || null,
663
- required: c.required || null,
664
- downloadUrl: c.downloadUrl || '',
665
- };
666
- }
667
-
668
- /**
669
- * The line for the welcome box when the SERVER says this build is too old.
670
- *
671
- * Distinct from `updateLine` on purpose: "a newer release exists" and "the
672
- * server will refuse you" are different urgency, and only the second one means
673
- * the app is about to stop working. Says where to get it, because a user who
674
- * is being cut off needs a destination, not a version number.
675
- */
676
- function serverUpdateLine({ behind, latest, required, downloadUrl } = {}) {
677
- if (!behind) return null;
678
- const target = latest || required || '';
679
- const head = target
680
- ? `This build is no longer served — update to v${target}.`
681
- : 'This build is no longer served — update to the latest release.';
682
- const where = downloadUrl ? ` ${downloadUrl}` : '';
683
- return `${head}${where}`;
684
- }
685
-
686
- module.exports = {
687
- isNewer,
688
- fetchLatest,
689
- fetchLatestDetailed,
690
- fetchServerVersion,
691
- fetchServerVersionDetailed,
692
- serverUpdateVerdict,
693
- serverNotice,
694
- serverUpdateLine,
695
- isTransientReason,
696
- updateNotice,
697
- checkPublished,
698
- updatePlan,
699
- updateLine,
700
- detectInstallMethod,
701
- APPLIERS,
702
- PKG,
703
- CHECK_INTERVAL_MS,
704
- REASONS,
705
- DEFAULT_API_BASE,
706
- };
3
+ const https=require("node:https"),fs=require("node:fs");let platformHelpers=null;try{platformHelpers=require("./platform.js")}catch{platformHelpers=null}const REGISTRY="https://registry.npmjs.org",PKG="aegiscode",CHECK_INTERVAL_MS=864e5,TIMEOUT_MS=2500;function isNewer(e,t){const n=e=>String(e||"").trim().replace(/^v/,"").split("-")[0].split(".").map((e=>Number.parseInt(e,10)||0)),r=n(e),s=n(t);for(let e=0;e<Math.max(r.length,s.length);e++){const t=r[e]||0,n=s[e]||0;if(t!==n)return t>n}return!1}const REASONS=Object.freeze({TIMEOUT:"timeout",NETWORK:"network",NOT_FOUND:"not-found",FORBIDDEN:"forbidden",HTTP:"http",MALFORMED:"malformed",TOO_LARGE:"too-large"});function isTransientReason(e,t=null){return e===REASONS.TIMEOUT||e===REASONS.NETWORK||e===REASONS.HTTP&&(!t||t>=500||429===t)}function fetchLatestDetailed({pkg:e=PKG,timeoutMs:t=TIMEOUT_MS}={}){return new Promise((n=>{let r=!1;const s=e=>{r||(r=!0,n(e))},l=(e,t=null)=>s({ok:!1,latest:null,reason:e,status:t});try{const n=https.get(`${REGISTRY}/${e}`,{headers:{accept:"application/vnd.npm.install-v1+json"},timeout:t},(e=>{const t=e.statusCode;if(200!==t)return e.resume(),l(404===t?REASONS.NOT_FOUND:401===t||403===t?REASONS.FORBIDDEN:REASONS.HTTP,t);let r="";e.setEncoding("utf8"),e.on("data",(e=>{r+=e,r.length>2e6&&(n.destroy(),l(REASONS.TOO_LARGE,t))})),e.on("end",(()=>{try{const e=JSON.parse(r)["dist-tags"],n=e&&e.latest||null;if(!n)return l(REASONS.MALFORMED,t);(e=>{s({ok:!0,latest:e,reason:null,status:200})})(n)}catch{l(REASONS.MALFORMED,t)}}))}));n.on("timeout",(()=>{n.destroy(),l(REASONS.TIMEOUT)})),n.on("error",(()=>l(REASONS.NETWORK)))}catch{l(REASONS.NETWORK)}}))}function fetchLatest(e={}){return fetchLatestDetailed(e).then((e=>e.ok?e.latest:null))}const DEFAULT_API_BASE="https://aegiscloud.org",VERSION_BODY_CAP=65536;function fetchServerVersionDetailed({base:e=DEFAULT_API_BASE,clientVersion:t=null,timeoutMs:n=TIMEOUT_MS,getImpl:r=null}={}){return new Promise((s=>{let l=!1;const o=e=>{l||(l=!0,s(e))},a=(e,t=null)=>o({ok:!1,minClient:null,latestCli:null,latestDesktop:null,upgradeRequired:null,downloadUrl:"",reason:e,status:t}),i=(e,n)=>{const r=e&&"string"==typeof e.min_client?e.min_client:null;if(!r)return a(REASONS.MALFORMED,n);o({ok:!0,minClient:r,latestCli:"string"==typeof e.latest_cli?e.latest_cli:"",latestDesktop:"string"==typeof e.latest_desktop?e.latest_desktop:"",upgradeRequired:t&&"boolean"==typeof e.upgrade_required?e.upgrade_required:null,downloadUrl:"string"==typeof e.download_url?e.download_url:"",reason:null,status:n})};try{const s=`${String(e||DEFAULT_API_BASE).replace(/\/+$/,"")}/api/v1/version`,o={accept:"application/json"};t&&(o["X-AEGIS-Version"]=String(t));const c=e=>{const t=e.statusCode;if(200!==t)return e.resume(),a(404===t?REASONS.NOT_FOUND:401===t||403===t?REASONS.FORBIDDEN:REASONS.HTTP,t);let n="";e.setEncoding("utf8"),e.on("data",(r=>{n+=r,n.length>VERSION_BODY_CAP&&(e.destroy(),a(REASONS.TOO_LARGE,t))})),e.on("end",(()=>{if(!l)try{i(JSON.parse(n),t)}catch{a(REASONS.MALFORMED,t)}}))};if("function"==typeof r)return void Promise.resolve(r({url:s,headers:o,timeoutMs:n})).then((e=>{const t=e&&null!=e.status?e.status:0;if(!t)return a(REASONS.NETWORK);if(200!==t)return a(404===t?REASONS.NOT_FOUND:401===t||403===t?REASONS.FORBIDDEN:REASONS.HTTP,t);const n=e&&"string"==typeof e.text?e.text:"";if(n.length>VERSION_BODY_CAP)return a(REASONS.TOO_LARGE,t);try{i(JSON.parse(n),t)}catch{a(REASONS.MALFORMED,t)}})).catch((()=>a(REASONS.NETWORK)));const u=require(/^http:\/\//i.test(s)?"node:http":"node:https").get(s,{headers:o,timeout:n},c);u.on("timeout",(()=>{u.destroy(),a(REASONS.TIMEOUT)})),u.on("error",(()=>a(REASONS.NETWORK)))}catch{a(REASONS.NETWORK)}}))}function fetchServerVersion(e={}){return fetchServerVersionDetailed(e).then((e=>e.ok?e.minClient:null))}async function serverUpdateVerdict({current:e,base:t=DEFAULT_API_BASE,timeoutMs:n=TIMEOUT_MS,getImpl:r=null}={}){const s=await fetchServerVersionDetailed({base:t,clientVersion:e,timeoutMs:n,getImpl:r});if(!s.ok)return{ok:!1,behind:!1,latest:null,required:null,downloadUrl:"",reason:s.reason,status:s.status};const l=s.minClient||null,o=!0===s.upgradeRequired||!1!==s.upgradeRequired&&!!l&&isNewer(l,e);return{ok:!0,behind:o,latest:o&&(s.latestCli||s.latestDesktop)||l,latestCli:s.latestCli,latestDesktop:s.latestDesktop,required:l,downloadUrl:s.downloadUrl,reason:null,status:s.status}}function updateNotice({current:e,cache:t,save:n,now:r=Date.now(),fetchImpl:s=fetchLatest,intervalMs:l=864e5}={}){const o=t||{};"number"==typeof o.checkedAt&&r-o.checkedAt<l||Promise.resolve(s()).then((e=>{e&&"function"==typeof n&&n({checkedAt:r,latest:e})})).catch((()=>{}));const a=o.latest||null;return{latest:a,behind:!(!a||!isNewer(a,e))}}function installSpawn(e,t={}){if(!e||"string"!=typeof e.command||!e.command)return null;const n=t&&"object"==typeof t?t:{},r="string"==typeof n.platform&&n.platform?n.platform:process.platform,s=n.env&&"object"==typeof n.env?n.env:process.env,l=void 0===n.helpers?platformHelpers:n.helpers,o=Array.isArray(e.args)?e.args.map(String):[],a=[e.command,...o].join(" ");if("win32"===r){const e=l&&"function"==typeof l.shellSpec?l.shellSpec({platform:r,env:s}):{cmd:"cmd.exe",arg:"/d /s /c"};return{command:e.cmd,args:[...String(e.arg).split(" "),a],shell:!1,script:a}}let i=e.command;if(l&&"function"==typeof l.resolveExecutable)try{const t=l.resolveExecutable(e.command,{platform:r,env:s,..."function"==typeof n.exists?{exists:n.exists}:{}});t&&(i=t)}catch{}return{command:i,args:o.slice(),shell:!1,script:a}}function updatePlan({current:e,latest:t,behind:n,channel:r="npm",pkg:s=PKG,method:l="npm"}={}){const o=String(null==e?"":e),a=t?String(t):null;if(!Boolean(n)||!Boolean(a))return{channel:r,method:l,current:o,latest:a,behind:!1,action:"none",applier:"none",instruction:null,apply:null};const i=APPLIERS[l]||APPLIERS.npm;return{channel:r,method:l,current:o,latest:a,behind:!0,action:i.action,applier:i.name,instruction:i.instruction(s),apply:i.apply?i.apply(s):null}}const APPLIERS=Object.freeze({npm:Object.freeze({name:"npm",action:"npm-update",instruction:e=>`npm i -g ${e}@latest`,apply:e=>({command:"npm",args:["i","-g",`${e}@latest`]})}),pip:Object.freeze({name:"pip",action:"pip-update",instruction:e=>`pip install -U ${e}`,apply:e=>({command:"win32"===process.platform?"python":"python3",args:["-m","pip","install","-U",e]})}),npx:Object.freeze({name:"none",action:"npx-update",instruction:()=>"already fetched fresh on every run — restart to pick up the new version",apply:null}),source:Object.freeze({name:"git",action:"source-update",instruction:()=>"git pull && npm ci (this copy is a source checkout)",apply:null})});function detectInstallMethod({env:e={},argv:t=[],pkg:n=PKG,fileExists:r=(()=>!1),entry:s=""}={}){const l=String(e&&e.AEGIS_INSTALL_METHOD||"").trim().toLowerCase();if(l&&Object.prototype.hasOwnProperty.call(APPLIERS,l))return l;const o=t.map((e=>String(e))).join(" ");return/\bnpx\b/.test(o)||/_npx[\\/]/.test(o)||/_npx[\\/]/.test(s)?"npx":/[\\/]pipx[\\/]venvs[\\/]/.test(s)||/[\\/]site-packages[\\/]/.test(s)?"pip":/[\\/]node_modules[\\/]/.test(s)?"npm":r(".git")&&r("client/update.js")?"source":"npm"}async function checkPublished({current:e,cache:t,save:n,now:r=Date.now(),fetchImpl:s=fetchLatestDetailed,intervalMs:l=864e5,timeoutMs:o=TIMEOUT_MS,pkg:a=PKG}={}){const i=t||null;if(Boolean(i)&&"number"==typeof i.checkedAt&&r-i.checkedAt<l){const t=i.latest||null;return{ok:Boolean(t),latest:t,behind:!(!t||!isNewer(t,e)),reason:t?null:i.reason||null,status:t?200:null==i.status?null:i.status,cached:!0,checkedAt:i.checkedAt}}let c;try{c=await s({pkg:a,timeoutMs:o})}catch(e){c={ok:!1,latest:null,reason:REASONS.NETWORK,status:null,error:String(e&&e.message||e)}}const u={checkedAt:r,latest:c&&c.latest?c.latest:null,reason:c&&c.reason||null,status:c&&null!=c.status?c.status:null};if("function"==typeof n)try{n(u)}catch{}return{ok:Boolean(c&&c.ok&&c.latest),latest:u.latest,behind:!(!u.latest||!isNewer(u.latest,e)),reason:u.reason,status:u.status,cached:!1,checkedAt:r}}function updateLine({current:e,latest:t,behind:n,pkg:r=PKG,method:s="npm"}){const l=updatePlan({current:e,latest:t,behind:n,channel:"npm",pkg:r,method:s});if(!l.behind)return null;const o=`Update available: ${l.current} → ${l.latest}`;return l.apply?`${o} run: ${l.instruction}`:`${o} ${l.instruction}`}function serverNotice({current:e,cache:t,save:n,now:r=Date.now(),verdictImpl:s=serverUpdateVerdict,intervalMs:l=864e5}={}){const o=t||{};return"number"==typeof o.checkedAt&&r-o.checkedAt<l||Promise.resolve().then((()=>s({current:e}))).then((e=>{e&&"function"==typeof n&&n({checkedAt:r,behind:Boolean(e.behind),required:e.required||null,latest:e.latest||null,downloadUrl:e.downloadUrl||"",reason:e.reason||null})})).catch((()=>{})),{behind:Boolean(o.behind),latest:o.latest||null,required:o.required||null,downloadUrl:o.downloadUrl||""}}function serverUpdateLine({behind:e,latest:t,required:n,downloadUrl:r}={}){if(!e)return null;const s=t||n||"";return`${s?`This build is no longer served — update to v${s}.`:"This build is no longer served — update to the latest release."}${r?` ${r}`:""}`}module.exports={isNewer:isNewer,fetchLatest:fetchLatest,fetchLatestDetailed:fetchLatestDetailed,fetchServerVersion:fetchServerVersion,fetchServerVersionDetailed:fetchServerVersionDetailed,serverUpdateVerdict:serverUpdateVerdict,serverNotice:serverNotice,serverUpdateLine:serverUpdateLine,isTransientReason:isTransientReason,updateNotice:updateNotice,checkPublished:checkPublished,updatePlan:updatePlan,installSpawn:installSpawn,updateLine:updateLine,detectInstallMethod:detectInstallMethod,APPLIERS:APPLIERS,PKG:PKG,CHECK_INTERVAL_MS:864e5,REASONS:REASONS,DEFAULT_API_BASE:DEFAULT_API_BASE};