heyquilt 0.0.1 → 0.3.90
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/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Webhooks for hosted agents, delivered by the relay: it holds every hosted agent's one
|
|
2
|
+
// subscription (in hosted-agents.json, beside the sessions the agent is in), watches each
|
|
3
|
+
// room's chat and task board, and POSTs to the agent when it is mentioned, sent a direct
|
|
4
|
+
// message or handed a task in any of them (see webhooks.js); each POST says which session
|
|
5
|
+
// and workspace it is from. Delivery from the relay works even when the agent itself is
|
|
6
|
+
// asleep, which is the point: its webhook wakes it.
|
|
7
|
+
//
|
|
8
|
+
// The subscription keeps its own inbox state per session (what it has already been told),
|
|
9
|
+
// separate from quilt_inbox's, so a POSTed event still shows there when the agent asks.
|
|
10
|
+
import { scanInbox } from './inbox.js'
|
|
11
|
+
import { hostedIn, newHosted } from './hosted-agents.js'
|
|
12
|
+
import { readTasks } from './tasks.js'
|
|
13
|
+
import { deliverEvents, makeSubscription } from './webhooks.js'
|
|
14
|
+
import { withContext } from './conversation.js'
|
|
15
|
+
|
|
16
|
+
/** The chat `me` can see: public messages, and direct ones to or from them. */
|
|
17
|
+
const chatFor = (doc, me) => doc.getArray('chat').toArray().filter((m) => m && m.id && (!m.to || m.to === me || m.by === me))
|
|
18
|
+
|
|
19
|
+
/** Each event with the conversation before it, from the room's kept chat (chat-archive.js) when it has one. */
|
|
20
|
+
const inContext = (room, me, events) => {
|
|
21
|
+
const all = room.chatArchive ? room.chatArchive.with(room.doc.getArray('chat').toArray()) : room.doc.getArray('chat').toArray()
|
|
22
|
+
return withContext(events, all.filter((m) => m && m.id && (!m.to || m.to === me || m.by === me)), { names: [me], agent: true })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const scan = (doc, name, state) => scanInbox({ messages: chatFor(doc, name), tasks: readTasks(doc.getMap('tasks')), reader: { name, asAi: false, agent: true } }, state)
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `hosted` is the relay's map of hosted agents (account id -> { room, ..., webhook? });
|
|
29
|
+
* `saveHosted` writes it. `fetch` and `delays` are for tests.
|
|
30
|
+
*/
|
|
31
|
+
export function hostedWebhooks ({ hosted, saveHosted, log = () => {}, fetch, delays } = {}) {
|
|
32
|
+
const sending = new Map() // account -> the delivery in flight, so one agent's POSTs keep their order
|
|
33
|
+
const watched = new WeakSet()
|
|
34
|
+
const opts = { log, ...(fetch ? { fetch } : {}), ...(delays ? { delays } : {}) }
|
|
35
|
+
|
|
36
|
+
/** Looks for new events in `room` for every subscribed hosted agent in it, and POSTs them. */
|
|
37
|
+
const check = (room) => {
|
|
38
|
+
let changed = false
|
|
39
|
+
for (const [account, h] of hosted) {
|
|
40
|
+
const sub = h && h.webhook
|
|
41
|
+
const r = sub && hostedIn(h, room.name)
|
|
42
|
+
if (!r) continue
|
|
43
|
+
// Only a member gets told: an agent still waiting to be let in sees nothing yet.
|
|
44
|
+
if (room.controlled && !room.meta.members[account]) continue
|
|
45
|
+
// A session the subscription has not taken stock of yet (joined while the room was not loaded,
|
|
46
|
+
// or an entry from before this relay): what is already there is not news.
|
|
47
|
+
if (r.webhookState === undefined) { r.webhookState = scan(room.doc, sub.name, null).state; changed = true; continue }
|
|
48
|
+
const got = scan(room.doc, sub.name, r.webhookState)
|
|
49
|
+
r.webhookState = got.state
|
|
50
|
+
changed = true
|
|
51
|
+
if (!got.events.length) continue
|
|
52
|
+
const events = inContext(room, sub.name, got.events)
|
|
53
|
+
const prev = sending.get(account) || Promise.resolve()
|
|
54
|
+
const next = prev.then(() => deliverEvents(sub, events, { room: room.name, to: sub.name, workspace: room.meta.workspace || '' }, opts)).catch(() => {})
|
|
55
|
+
sending.set(account, next)
|
|
56
|
+
next.then(() => { if (sending.get(account) === next) sending.delete(account) })
|
|
57
|
+
}
|
|
58
|
+
if (changed) saveHosted()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Watches a room's chat and board; safe to call more than once for the same room. */
|
|
62
|
+
const watch = (room) => {
|
|
63
|
+
if (watched.has(room)) return
|
|
64
|
+
watched.add(room)
|
|
65
|
+
const run = () => { try { check(room) } catch (err) { log(`[${room.name}] webhooks: ${err.message}`) } }
|
|
66
|
+
room.doc.getArray('chat').observe(run)
|
|
67
|
+
room.doc.getMap('tasks').observeDeep(run)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Makes `account` (named `name`) its one subscription from what the agent gave, taking stock
|
|
72
|
+
* of every session it is in (`rooms`: the loaded ones) so nothing already there is POSTed.
|
|
73
|
+
* Returns it.
|
|
74
|
+
*/
|
|
75
|
+
const subscribe = (account, { name, rooms = [] }, given) => {
|
|
76
|
+
// Set before any session too (a workspace agent waits for session.started): the entry starts here.
|
|
77
|
+
let h = hosted.get(account)
|
|
78
|
+
if (!h) { h = newHosted(); hosted.set(account, h) }
|
|
79
|
+
const sub = makeSubscription(given)
|
|
80
|
+
h.webhook = { url: sub.url, secret: sub.secret, events: sub.events, since: sub.since, name, ...(sub.bearer ? { bearer: sub.bearer } : {}) }
|
|
81
|
+
for (const r of Object.values(h.rooms || {})) delete r.webhookState
|
|
82
|
+
for (const room of rooms) takeStock(account, room)
|
|
83
|
+
saveHosted()
|
|
84
|
+
return { ...h.webhook, made: sub.made }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The agent joined `room` (again): what is there already is not news to its webhook. */
|
|
88
|
+
const takeStock = (account, room) => {
|
|
89
|
+
const h = hosted.get(account)
|
|
90
|
+
const r = h && h.webhook && hostedIn(h, room.name)
|
|
91
|
+
if (!r) return
|
|
92
|
+
r.webhookState = scan(room.doc, h.webhook.name, null).state
|
|
93
|
+
saveHosted()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const unsubscribe = (account) => {
|
|
97
|
+
const h = hosted.get(account)
|
|
98
|
+
const had = !!(h && h.webhook)
|
|
99
|
+
if (had) {
|
|
100
|
+
delete h.webhook
|
|
101
|
+
for (const r of Object.values(h.rooms || {})) delete r.webhookState
|
|
102
|
+
saveHosted()
|
|
103
|
+
}
|
|
104
|
+
return had
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Waits for every POST in flight (tests, shutdown). */
|
|
108
|
+
const idle = () => Promise.all([...sending.values()])
|
|
109
|
+
|
|
110
|
+
return { check, watch, subscribe, takeStock, unsubscribe, idle }
|
|
111
|
+
}
|
package/src/releases.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Release notes and the update check. RELEASES.md (shipped with the app) says what changed
|
|
2
|
+
// in each version; GitHub's latest release says whether a newer build exists.
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { getSettings, saveSettings } from './settings.js'
|
|
7
|
+
|
|
8
|
+
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
9
|
+
export const REPO = 'DanielCarmichaelGit/heyquilt'
|
|
10
|
+
const LATEST_API = `https://api.github.com/repos/${REPO}/releases/latest`
|
|
11
|
+
const RELEASES_PAGE = `https://github.com/${REPO}/releases`
|
|
12
|
+
const CACHE_MS = 10 * 60 * 1000
|
|
13
|
+
|
|
14
|
+
/** The version this app is, from package.json. */
|
|
15
|
+
export function currentVersion () {
|
|
16
|
+
return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The sections of a RELEASES.md: [{ version, date, summary, items }], in file order.
|
|
21
|
+
* A section starts at `## <version> — <date>`; its first plain line is the summary and
|
|
22
|
+
* its `- ` lines are the items. Anything before the first section is ignored.
|
|
23
|
+
*/
|
|
24
|
+
export function parseReleases (md) {
|
|
25
|
+
const out = []
|
|
26
|
+
let cur = null
|
|
27
|
+
for (const raw of String(md).split(/\r?\n/)) {
|
|
28
|
+
const line = raw.trimEnd()
|
|
29
|
+
const head = line.match(/^## +v?(\d+\.\d+\.\d+)\s*(?:[—–-]+\s*(\d{4}-\d{2}-\d{2}))?\s*$/)
|
|
30
|
+
if (head) { cur = { version: head[1], date: head[2] || '', summary: '', items: [] }; out.push(cur); continue }
|
|
31
|
+
if (!cur) continue
|
|
32
|
+
const item = line.match(/^[-*] +(.*)$/)
|
|
33
|
+
if (item) cur.items.push(item[1].trim())
|
|
34
|
+
else if (line.trim() && !cur.items.length && !cur.summary && !/^#/.test(line)) cur.summary = line.trim()
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let local = null
|
|
40
|
+
/** The releases listed in this app's RELEASES.md, newest first. */
|
|
41
|
+
export function localReleases () {
|
|
42
|
+
if (local) return local
|
|
43
|
+
let md = ''
|
|
44
|
+
try { md = fs.readFileSync(path.join(ROOT, 'RELEASES.md'), 'utf8') } catch {}
|
|
45
|
+
local = parseReleases(md).sort((a, b) => compareVersions(b.version, a.version))
|
|
46
|
+
return local
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Semver order: negative when a < b, 0 when equal, positive when a > b. Unparseable versions sort lowest. */
|
|
50
|
+
export function compareVersions (a, b) {
|
|
51
|
+
const num = (v) => { const m = String(v || '').match(/(\d+)\.(\d+)\.(\d+)/); return m ? m.slice(1, 4).map(Number) : null }
|
|
52
|
+
const x = num(a); const y = num(b)
|
|
53
|
+
if (!x && !y) return 0
|
|
54
|
+
if (!x) return -1
|
|
55
|
+
if (!y) return 1
|
|
56
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i]
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Where to get the newest build for this computer. */
|
|
61
|
+
export function downloadUrl (platform = process.platform, arch = process.arch) {
|
|
62
|
+
const file = platform === 'darwin' ? `quilt-mac-${arch === 'arm64' ? 'arm64' : 'x64'}.dmg`
|
|
63
|
+
: platform === 'win32' ? 'quilt-windows-x64.exe'
|
|
64
|
+
: platform === 'linux' ? `quilt-linux-${arch === 'arm64' ? 'arm64' : 'x86_64'}.AppImage`
|
|
65
|
+
: null
|
|
66
|
+
return file ? `${RELEASES_PAGE}/latest/download/${file}` : `${RELEASES_PAGE}/latest`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The notes inside a GitHub release body written by releaseNotesBody (or by hand in that shape). */
|
|
70
|
+
function parseGitHubBody (body) {
|
|
71
|
+
const text = String(body || '').split(/\n\*\*Downloads\*\*/)[0].replace(/^\*\*What's new in [^*]*\*\*\s*/i, '')
|
|
72
|
+
const r = parseReleases(`## 0.0.0\n${text}`)[0] || { summary: '', items: [] }
|
|
73
|
+
return { summary: r.summary, items: r.items }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const cache = new Map() // url -> { at, release }
|
|
77
|
+
/**
|
|
78
|
+
* The newest release on GitHub: { version, url, date, summary, items }, or null when it can't be
|
|
79
|
+
* reached and nothing is cached. Asks at most every ten minutes; a failed refresh keeps the last answer.
|
|
80
|
+
*/
|
|
81
|
+
export async function latestRelease ({ url = process.env.QUILT_RELEASES_URL || LATEST_API, now = Date.now, timeoutMs = 6000 } = {}) {
|
|
82
|
+
const hit = cache.get(url)
|
|
83
|
+
if (hit && now() - hit.at < CACHE_MS) return hit.release
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(url, { headers: { accept: 'application/vnd.github+json', 'user-agent': `quilt/${currentVersion()}` }, signal: AbortSignal.timeout(timeoutMs) })
|
|
86
|
+
if (!res.ok) throw new Error(`GitHub answered ${res.status}`)
|
|
87
|
+
const r = await res.json()
|
|
88
|
+
const version = String(r.tag_name || '').replace(/^v/, '')
|
|
89
|
+
if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error('no version in the release')
|
|
90
|
+
const release = { version, url: r.html_url || `${RELEASES_PAGE}/tag/v${version}`, date: String(r.published_at || '').slice(0, 10), ...parseGitHubBody(r.body) }
|
|
91
|
+
cache.set(url, { at: now(), release })
|
|
92
|
+
return release
|
|
93
|
+
} catch {
|
|
94
|
+
if (hit) { hit.at = now(); return hit.release } // try again in ten minutes
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The last version whose notes this computer has seen. */
|
|
100
|
+
export function seenVersion () { return String(getSettings().seenRelease || '') }
|
|
101
|
+
export function markSeen (version) { saveSettings({ seenRelease: String(version || '') }) }
|
|
102
|
+
|
|
103
|
+
// "Don't show this again" on the update bar: the newer release it was said for. Kept in the
|
|
104
|
+
// settings file, not the page's storage (the app's address changes every launch), and only
|
|
105
|
+
// for that release: a newer one shows the bar again.
|
|
106
|
+
export function hiddenUpdate () { return String(getSettings().hiddenUpdate || '') }
|
|
107
|
+
export function hideUpdate (version) {
|
|
108
|
+
const v = String(version || '')
|
|
109
|
+
if (!/^\d+\.\d+\.\d+$/.test(v)) throw new Error('that is not a version')
|
|
110
|
+
saveSettings({ hiddenUpdate: v })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The GitHub release body for a RELEASES.md section: the notes, then where to download. */
|
|
114
|
+
export function releaseNotesBody (release) {
|
|
115
|
+
const lines = [`**What's new in ${release.version}**`, '']
|
|
116
|
+
if (release.summary) lines.push(release.summary, '')
|
|
117
|
+
for (const it of release.items) lines.push(`- ${it}`)
|
|
118
|
+
lines.push('', '**Downloads**',
|
|
119
|
+
'- Mac (Apple silicon): quilt-mac-arm64.dmg',
|
|
120
|
+
'- Mac (Intel): quilt-mac-x64.dmg',
|
|
121
|
+
'- Windows: quilt-windows-x64.exe',
|
|
122
|
+
'- Linux desktop: quilt-linux-x86_64.AppImage (Intel/AMD), quilt-linux-arm64.AppImage (ARM)',
|
|
123
|
+
`- Linux servers and cloud machines (no desktop): \`curl -fsSL ${RELEASES_PAGE}/latest/download/install.sh | sh\``,
|
|
124
|
+
'',
|
|
125
|
+
'**Note on signing:** these builds are unsigned (no Apple Developer ID / notarization, no Windows code-signing certificate configured). Windows SmartScreen or macOS Gatekeeper may warn on first launch.',
|
|
126
|
+
'',
|
|
127
|
+
'- **macOS:** if you see "Apple could not verify Quilt is free of malware," click **Done**, then open **System Settings → Privacy & Security**, scroll down and click **Open Anyway** next to Quilt. You only need to do this once. (On older macOS you can instead right-click the app and choose **Open**.)',
|
|
128
|
+
'- **Windows:** if SmartScreen shows "Windows protected your PC," click **More info**, then **Run anyway**.',
|
|
129
|
+
'- **Linux:** make the AppImage runnable (`chmod +x quilt-linux-*.AppImage`) and open it. If it says FUSE is missing, install `libfuse2` (or run it with `--appimage-extract-and-run`).')
|
|
130
|
+
return lines.join('\n') + '\n'
|
|
131
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// src/report.js
|
|
2
|
+
// Tells Quilt what went wrong in the app (and how long actions took) so problems can be
|
|
3
|
+
// found without anyone filing them. Batches reports to the accounts API, scrubs anything
|
|
4
|
+
// personal first, and never gets in the way: it drops rather than retries, and never throws.
|
|
5
|
+
import { apiUrl } from './account.js'
|
|
6
|
+
import { currentVersion } from './releases.js'
|
|
7
|
+
|
|
8
|
+
// A path on this computer says who you are and how your disk is laid out; its last part
|
|
9
|
+
// (the project folder, the app) is all a report needs. A directory segment may be a few
|
|
10
|
+
// space-joined words of its own ("Program Files (x86)"), so each segment allows up to four.
|
|
11
|
+
// A UNC path (\\server\share\...) is a root of its own kind, same treatment.
|
|
12
|
+
const PATH = /(?:~|[A-Za-z]:|\/(?:Users|home|private|tmp|var|opt|Applications|Volumes|root|mnt|srv|data|workspace)|\\\\[^\s\\]+)(?:[\\/][^\s'"`:,\\/]+(?: [^\s'"`:,\\/]+){0,3})*/g
|
|
13
|
+
// Every token prefix Quilt mints: qd_ (device), qa_/qr_ (agent access/refresh), dc_ (device
|
|
14
|
+
// code), qi_ (org invite), qj_ (agent join). No \b: `_` is a word character, so `\b` would miss
|
|
15
|
+
// a token straight after an underscore (`_qd_...`); instead, the character right before the
|
|
16
|
+
// prefix (if any) just has to not be alphanumeric, and that character is kept in the output.
|
|
17
|
+
const TOKEN = /(?:^|[^A-Za-z0-9])((?:qd|qa|qr|dc|qi|qj)_[A-Za-z0-9_-]+)/g
|
|
18
|
+
// join.heyquilt.com and quilt:// links; also the older/relay form of an invite link, any host,
|
|
19
|
+
// where the secret lives after a `#` on a `/join/...` path.
|
|
20
|
+
const INVITE = /(?:https?:\/\/join\.heyquilt\.com\/[^\s'"`]+|quilt:\/\/[^\s'"`]+|\/join\/[^\s#'"`]+#\S+)/g
|
|
21
|
+
// A username:password (or token) embedded in a URL, e.g. https://x-access-token:ghp_x@github.com/.
|
|
22
|
+
const CREDS = /:\/\/[^/\s:@]+:[^@\s]+@/g
|
|
23
|
+
// GitHub tokens (classic and fine-grained) and JWTs (a Bearer token, including Supabase's).
|
|
24
|
+
const SECRET = /\b(?:gh[pousr]_|github_pat_)\w+|eyJ[\w-]+\.[\w-]+\.[\w-]+/g
|
|
25
|
+
|
|
26
|
+
/** `text` with paths cut to their last part, and tokens, credentials and invite links hidden. */
|
|
27
|
+
export function scrub (text) {
|
|
28
|
+
return String(text ?? '')
|
|
29
|
+
.replace(INVITE, '[invite]')
|
|
30
|
+
.replace(CREDS, '://[secret]@')
|
|
31
|
+
.replace(SECRET, '[secret]')
|
|
32
|
+
.replace(TOKEN, (m, token) => m.slice(0, m.length - token.length) + '[secret]')
|
|
33
|
+
// PATH can absorb a few words of trailing prose past the path's last separator (nothing
|
|
34
|
+
// stops it there) — that's harmless: .pop() returns whatever follows the last separator
|
|
35
|
+
// verbatim, spaces and all, so absorbed prose comes back unchanged. The safety invariant
|
|
36
|
+
// lives here, in the replacement, not in how precisely the regex draws the line.
|
|
37
|
+
.replace(PATH, (m) => m.split(/[\\/]/).filter(Boolean).pop() || '[path]')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const scrubContext = (c) => Object.fromEntries(Object.entries(c && typeof c === 'object' ? c : {}).map(([k, v]) => [k, typeof v === 'string' ? scrub(v) : v]))
|
|
41
|
+
|
|
42
|
+
export function createReporter ({
|
|
43
|
+
token = () => null, enabled = () => true, fetch = globalThis.fetch, api = apiUrl(), version = currentVersion(), platform = process.platform,
|
|
44
|
+
now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout,
|
|
45
|
+
batchSize = 20, delayMs = 10_000, maxWaiting = 50, backoffMs = 60_000, log = () => {}
|
|
46
|
+
} = {}) {
|
|
47
|
+
let waiting = []
|
|
48
|
+
let timer = null
|
|
49
|
+
let pausedUntil = 0
|
|
50
|
+
let inflight = null
|
|
51
|
+
let closed = false
|
|
52
|
+
|
|
53
|
+
function record (event) {
|
|
54
|
+
try {
|
|
55
|
+
if (closed || !enabled()) return
|
|
56
|
+
const { kind, name, outcome = 'ok', status, durationMs, message = '', context = {} } = event || {}
|
|
57
|
+
waiting.push({ kind, name: String(name || '').slice(0, 80), outcome, status, durationMs, message: scrub(message).slice(0, 500), context: scrubContext(context), occurredAt: now() })
|
|
58
|
+
if (waiting.length > maxWaiting) waiting.splice(0, waiting.length - maxWaiting)
|
|
59
|
+
if (waiting.length >= batchSize) send()
|
|
60
|
+
else schedule()
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function schedule () {
|
|
65
|
+
if (closed || timer || !waiting.length) return
|
|
66
|
+
const delay = Math.max(delayMs, pausedUntil - now())
|
|
67
|
+
timer = setTimer(() => { timer = null; send() }, delay)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Dropped, not retried: a report is never worth a retry storm. One quiet line.
|
|
71
|
+
function fail (err) {
|
|
72
|
+
pausedUntil = now() + backoffMs
|
|
73
|
+
log(`issue report not sent: ${err?.message || err}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function send () {
|
|
77
|
+
if (inflight || !waiting.length) return
|
|
78
|
+
if (now() < pausedUntil) return schedule()
|
|
79
|
+
if (timer) { clearTimer(timer); timer = null }
|
|
80
|
+
const events = waiting.splice(0, batchSize)
|
|
81
|
+
try {
|
|
82
|
+
const t = token()
|
|
83
|
+
inflight = Promise.resolve().then(() => fetch(`${api}/v1/issues`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'content-type': 'application/json', ...(t ? { authorization: `Bearer ${t}` } : {}) },
|
|
86
|
+
body: JSON.stringify({ surface: 'app', appVersion: version, platform, events })
|
|
87
|
+
})).then((res) => {
|
|
88
|
+
if (!res || !res.ok) throw new Error(`Quilt answered ${res && res.status}`)
|
|
89
|
+
}).catch(fail).finally(() => {
|
|
90
|
+
inflight = null
|
|
91
|
+
if (waiting.length) schedule()
|
|
92
|
+
})
|
|
93
|
+
return inflight
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// token() (or anything else synchronous above) threw before any request went out:
|
|
96
|
+
// the batch taken off `waiting` is still dropped, same as a failed send.
|
|
97
|
+
fail(err)
|
|
98
|
+
if (waiting.length) schedule()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Sends what is waiting now, batch after batch, until nothing is left or `timeoutMs` runs
|
|
104
|
+
* out. If a send has nothing to wait on — paused by backoff, or nothing left to send — it
|
|
105
|
+
* stops right there rather than spin: at shutdown a failed batch is dropped, not retried,
|
|
106
|
+
* same as any other time.
|
|
107
|
+
*/
|
|
108
|
+
async function flush ({ timeoutMs = 2000 } = {}) {
|
|
109
|
+
const deadline = Date.now() + timeoutMs
|
|
110
|
+
pausedUntil = 0
|
|
111
|
+
while ((waiting.length || inflight) && Date.now() < deadline) {
|
|
112
|
+
const step = inflight || send()
|
|
113
|
+
if (!step) break
|
|
114
|
+
let timeoutId
|
|
115
|
+
const timeout = new Promise((res) => { timeoutId = setTimeout(res, Math.max(0, deadline - Date.now())) })
|
|
116
|
+
await Promise.race([step, timeout]).catch(() => {})
|
|
117
|
+
clearTimeout(timeoutId)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Stops any future scheduling and flushes what is left, so shutdown never leaves a timer armed. */
|
|
122
|
+
function close () {
|
|
123
|
+
closed = true
|
|
124
|
+
if (timer) { clearTimer(timer); timer = null }
|
|
125
|
+
return flush()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { record, flush, close, waiting: () => waiting.length }
|
|
129
|
+
}
|