heyquilt 0.0.1 → 0.3.106

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 (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +133 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1219 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
@@ -0,0 +1,132 @@
1
+ // An agent's Keys table in the app (Settings › Agents, opened from its card; mockup
2
+ // docs/mockups/2026-10-18-sandbox-keys.html, option A): its sandbox keys (QUILT_AGENT_KEY), each
3
+ // with its session, last use, use over seven days and state, Rotate and Revoke (Remove once
4
+ // revoked). New key opens a row at the top; a new key shows once, with Copy. The words and numbers
5
+ // are sandbox-keys-model.js, which heyquilt.com shares.
6
+ import { I, esc, api, toast } from './common.js'
7
+ import { usedText, sparkHeights, stateLabel, stateClass, keyLabel, sessionTone, matchKeys, sessionOptions } from './sandbox-keys-model.js'
8
+
9
+ const KEY = '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="5" cy="8" r="3" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 8h6M12 8v2.5M14 8v2" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>'
10
+ const ROTATE = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13 8a5 5 0 1 1-1.5-3.5M13 2v3h-3" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>'
11
+
12
+ function sparkHtml (k) {
13
+ if (k.state === 'revoked') return ''
14
+ const total = k.uses.reduce((a, b) => a + b, 0)
15
+ return `<span class="sk-spark" title="${total} ${total === 1 ? 'request' : 'requests'} in 7 days">${sparkHeights(k.uses).map((h) => `<i${h ? ` style="height:${h}%"` : ' class="z"'}></i>`).join('')}</span>`
16
+ }
17
+
18
+ function rowHtml (k, now, q) {
19
+ const hidden = matchKeys([k], q).length ? '' : ' hidden'
20
+ const revoked = k.state === 'revoked'
21
+ const acts = revoked
22
+ ? `<button type="button" class="btn sm ghost" data-sk-remove="${esc(k.id)}">Remove</button>`
23
+ : `<button type="button" class="btn sm" data-sk-rotate="${esc(k.id)}">${ROTATE}<span>Rotate</span></button><button type="button" class="btn sm danger" data-sk-revoke="${esc(k.id)}"><span>Revoke</span></button>`
24
+ return `<tr data-sk-row="${esc(k.id)}"${revoked ? ' class="sk-revoked"' : ''}${hidden}>
25
+ <td><b>${esc(k.name)}</b><div class="sk-hint">${esc(keyLabel(k.hint))}</div></td>
26
+ <td><span class="sk-chip" title="${esc(k.session)}"><span class="sk-dot t${sessionTone(k.room)}"></span><span class="sk-chip-t">${esc(k.session)}</span></span></td>
27
+ <td class="sk-n">${esc(usedText(k.lastUsedAt, now))}</td>
28
+ <td class="sk-n sk-m">${sparkHtml(k)}</td>
29
+ <td><span class="pill ${stateClass(k.state)}">${stateLabel(k.state)}</span></td>
30
+ <td class="sk-act"><span>${acts}</span></td>
31
+ </tr>`
32
+ }
33
+
34
+ function newRowHtml (sessions) {
35
+ const where = sessions.length
36
+ ? `<select class="input sm" name="sk-session" aria-label="Session">${sessions.map((s) => `<option value="${esc(s.value)}">${esc(s.name)}</option>`).join('')}</select>`
37
+ : '<input class="input sm" name="sk-link" type="url" placeholder="Session invite link" aria-label="Session invite link" autocomplete="off">'
38
+ return `<tr class="sk-newrow"><td colspan="6"><form class="sk-inline" data-sk-form>
39
+ <input class="input sm" name="sk-name" maxlength="40" placeholder="Name, e.g. bolt.new · checkout" aria-label="Key name" autocomplete="off" required>
40
+ ${where}
41
+ <button type="submit" class="btn primary sm" data-sk-create>Create</button><button type="button" class="btn ghost sm" data-sk-cancel>Cancel</button>
42
+ </form></td></tr>`
43
+ }
44
+
45
+ /**
46
+ * The Keys card for `agent`. `v`: { keys, sessions (picker options), adding, fresh ({ envKey }),
47
+ * error, query, loading, now }.
48
+ */
49
+ export function keysCardHtml (agent, v) {
50
+ const now = v.now || Date.now()
51
+ const keys = v.keys || []
52
+ const body = v.loading
53
+ ? '<tr><td colspan="6" class="hint">Loading…</td></tr>'
54
+ : keys.length ? keys.map((k) => rowHtml(k, now, v.query)).join('') : '<tr><td colspan="6" class="hint">No keys yet.</td></tr>'
55
+ return `<div class="sk-card card" data-sk-agent="${esc(agent.id)}">
56
+ <div class="sk-head"><span class="sk-ico">${KEY}</span><span class="sk-t">Keys</span><span class="sk-who">${esc(agent.name)} (agent)</span><span class="sk-grow"></span>
57
+ <label class="sk-search"><span class="sr-only">Search keys</span><input class="input sm" data-sk-search type="search" placeholder="Search keys" value="${esc(v.query || '')}" autocomplete="off"></label>
58
+ <button type="button" class="btn primary sm" data-sk-new${v.adding ? ' disabled' : ''}>New key</button></div>
59
+ ${v.fresh ? `<div class="sk-once"><span class="pill live">New</span><code class="sk-key" data-sk-value>${esc(v.fresh.envKey)}</code><span class="sk-acts"><button type="button" class="btn sm" data-sk-copy>${I.copy}<span>Copy</span></button><span class="hint">shown once</span></span></div>` : ''}
60
+ ${v.error ? `<p class="error sk-error">${esc(v.error)}</p>` : ''}
61
+ <div class="sk-scroll"><table class="sk-tbl">
62
+ <thead><tr><th>Key</th><th>Session</th><th class="sk-n">Last used</th><th class="sk-n sk-m">Use, 7 days</th><th>Status</th><th><span class="sr-only">Actions</span></th></tr></thead>
63
+ <tbody>${v.adding ? newRowHtml(v.sessions || []) : ''}${body}</tbody>
64
+ </table></div>
65
+ </div>`
66
+ }
67
+
68
+ /** Draws and runs one agent's Keys card in `el`. */
69
+ export function bindSandboxKeys (el, agent) {
70
+ const base = `/api/agents/${encodeURIComponent(agent.id)}/sandbox-keys`
71
+ const v = { keys: [], sessions: [], adding: false, fresh: null, error: '', query: '', loading: true }
72
+ const draw = () => { el.innerHTML = keysCardHtml(agent, v) }
73
+ const load = async () => {
74
+ try {
75
+ const r = await api('GET', base)
76
+ Object.assign(v, { keys: r.keys, sessions: sessionOptions(r.local, r.sessions), loading: false })
77
+ } catch (err) { Object.assign(v, { loading: false, error: err.message }) }
78
+ draw()
79
+ }
80
+ // Rotate and Revoke ask once more on the button itself (no dialog): a second click within 4 s does it.
81
+ const armed = (b, label) => {
82
+ if (b.dataset.armed) return true
83
+ b.dataset.armed = '1'
84
+ b.classList.add('sk-armed')
85
+ b.querySelector('span').textContent = label
86
+ setTimeout(() => { if (b.isConnected) { delete b.dataset.armed; b.classList.remove('sk-armed'); b.querySelector('span').textContent = label.replace(' now', '') } }, 4000)
87
+ return false
88
+ }
89
+ const act = async (fn) => {
90
+ v.error = ''
91
+ try { await fn() } catch (err) { v.error = err.message }
92
+ await load()
93
+ }
94
+ el.addEventListener('click', async (e) => {
95
+ const b = e.target.closest('button')
96
+ if (!b || b.disabled) return
97
+ if (b.matches('[data-sk-new]')) { v.adding = true; draw(); el.querySelector('[name="sk-name"]')?.focus(); return }
98
+ if (b.matches('[data-sk-cancel]')) { v.adding = false; v.error = ''; draw(); return }
99
+ if (b.matches('[data-sk-copy]')) {
100
+ try { await navigator.clipboard.writeText(v.fresh.envKey); toast('Copied') } catch { toast('Select the key and press ⌘/Ctrl+C to copy') }
101
+ return
102
+ }
103
+ if (b.dataset.skRotate && armed(b, 'Rotate now')) {
104
+ b.disabled = true
105
+ return act(async () => { const r = await api('POST', `${base}/rotate`, { keyId: b.dataset.skRotate }); v.fresh = { envKey: r.envKey } })
106
+ }
107
+ if (b.dataset.skRevoke && armed(b, 'Revoke now')) {
108
+ b.disabled = true
109
+ return act(() => api('POST', `${base}/revoke`, { keyId: b.dataset.skRevoke }))
110
+ }
111
+ if (b.dataset.skRemove) { b.disabled = true; return act(() => api('POST', `${base}/remove`, { keyId: b.dataset.skRemove })) }
112
+ })
113
+ el.addEventListener('submit', async (e) => {
114
+ e.preventDefault()
115
+ const f = e.target
116
+ const name = f.elements['sk-name'].value.trim()
117
+ const pick = f.elements['sk-session']?.value || ''
118
+ const link = f.elements['sk-link']?.value.trim() || ''
119
+ f.querySelector('[data-sk-create]').disabled = true
120
+ await act(async () => {
121
+ const r = await api('POST', base, { name, ...(pick ? { session: pick } : { link }) })
122
+ Object.assign(v, { fresh: { envKey: r.envKey }, adding: false })
123
+ })
124
+ })
125
+ el.addEventListener('input', (e) => {
126
+ if (!e.target.matches('[data-sk-search]')) return
127
+ v.query = e.target.value
128
+ for (const tr of el.querySelectorAll('[data-sk-row]')) tr.hidden = !matchKeys(v.keys.filter((k) => k.id === tr.dataset.skRow), v.query).length
129
+ })
130
+ draw()
131
+ load()
132
+ }
@@ -0,0 +1,156 @@
1
+ // Repeat schedules for task cards. Phrases become 5-field cron; the card shows
2
+ // the phrase back in words. No DOM, so the session server can import it too.
3
+
4
+ const INVISIBLE = /[\u0000-\u001f\u007f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g
5
+ const RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]]
6
+ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
7
+ const DOW = {
8
+ sunday: 0, sun: 0, monday: 1, mon: 1, tuesday: 2, tue: 2, tues: 2,
9
+ wednesday: 3, wed: 3, thursday: 4, thu: 4, thur: 4, thurs: 4,
10
+ friday: 5, fri: 5, saturday: 6, sat: 6
11
+ }
12
+
13
+ function pieceOk (part, min, max) {
14
+ const m = /^(?:(\*)|(\d+)|(\d+-\d+))(?:\/(\d+))?$/.exec(part)
15
+ if (!m) return false
16
+ if (m[4] != null) {
17
+ const step = Number(m[4])
18
+ if (!Number.isInteger(step) || step < 1 || step > max) return false
19
+ }
20
+ if (m[1]) return true
21
+ if (m[2]) {
22
+ const n = Number(m[2])
23
+ return n >= min && n <= max
24
+ }
25
+ const [a, b] = m[3].split('-').map(Number)
26
+ return a >= min && b <= max && a <= b
27
+ }
28
+
29
+ function fieldOk (field, min, max) {
30
+ if (!field || field.length > 40) return false
31
+ return field.split(',').every((part) => pieceOk(part, min, max))
32
+ }
33
+
34
+ /** True for a 5-field cron expression (numbers, lists, ranges, stars). */
35
+ export function isCron (value) {
36
+ const parts = String(value ?? '').trim().split(/\s+/)
37
+ if (parts.length !== 5) return false
38
+ return parts.every((field, i) => fieldOk(field, RANGES[i][0], RANGES[i][1]))
39
+ }
40
+
41
+ function clock (hour, minute, ampm) {
42
+ let h = Number(hour)
43
+ const m = minute == null || minute === '' ? 0 : Number(minute)
44
+ if (!Number.isInteger(h) || !Number.isInteger(m) || m < 0 || m > 59) return null
45
+ if (ampm) {
46
+ const ap = ampm.toLowerCase()
47
+ if (h > 12 || h < 1) return null
48
+ if (ap === 'pm' && h < 12) h += 12
49
+ if (ap === 'am' && h === 12) h = 0
50
+ } else if (h > 23 || h < 0) return null
51
+ return { h, m }
52
+ }
53
+
54
+ function phraseToCron (raw) {
55
+ const s = raw.toLowerCase()
56
+ if (s === 'every minute' || s === 'minutely') return '* * * * *'
57
+ if (s === 'hourly' || s === 'every hour') return '0 * * * *'
58
+ if (s === 'daily' || s === 'every day') return '0 0 * * *'
59
+ if (s === 'weekly' || s === 'every week') return '0 0 * * 1'
60
+ if (s === 'monthly' || s === 'every month') return '0 0 1 * *'
61
+ if (s === 'weekdays' || s === 'every weekday') return '0 0 * * 1-5'
62
+ if (s === 'weekends' || s === 'every weekend') return '0 0 * * 0,6'
63
+
64
+ let m = s.match(/^every (\d{1,2}) minutes?$/)
65
+ if (m) {
66
+ const n = Number(m[1])
67
+ if (n < 1 || n > 59) return null
68
+ return n === 1 ? '* * * * *' : `*/${n} * * * *`
69
+ }
70
+ m = s.match(/^every (\d{1,2}) hours?$/)
71
+ if (m) {
72
+ const n = Number(m[1])
73
+ if (n < 1 || n > 23) return null
74
+ return n === 1 ? '0 * * * *' : `0 */${n} * * *`
75
+ }
76
+
77
+ m = s.match(/^(every day|daily|weekdays|every weekday|weekends|every weekend|weekly|every week|monthly|every month) at (\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/)
78
+ if (m) {
79
+ const c = clock(m[2], m[3], m[4])
80
+ if (!c) return null
81
+ const kind = m[1]
82
+ if (kind === 'monthly' || kind === 'every month') return `${c.m} ${c.h} 1 * *`
83
+ const dow = kind === 'weekdays' || kind === 'every weekday' ? '1-5'
84
+ : kind === 'weekends' || kind === 'every weekend' ? '0,6'
85
+ : kind === 'weekly' || kind === 'every week' ? '1'
86
+ : '*'
87
+ return `${c.m} ${c.h} * * ${dow}`
88
+ }
89
+
90
+ m = s.match(/^(?:every|on)\s+(sunday|sun|monday|mon|tuesday|tue|tues|wednesday|wed|thursday|thu|thur|thurs|friday|fri|saturday|sat)(?: at (\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/)
91
+ if (m) {
92
+ const c = m[2] != null ? clock(m[2], m[3], m[4]) : { h: 0, m: 0 }
93
+ if (!c) return null
94
+ return `${c.m} ${c.h} * * ${DOW[m[1]]}`
95
+ }
96
+ return null
97
+ }
98
+
99
+ /**
100
+ * A stored schedule or a phrase ("daily at 9", "weekdays at 9:30", "every monday").
101
+ * Empty clears it. Throws when it is neither a phrase nor 5-field cron.
102
+ */
103
+ export function parseSchedule (input) {
104
+ const raw = String(input ?? '').replace(INVISIBLE, ' ').replace(/\s+/g, ' ').trim()
105
+ if (!raw) return ''
106
+ if (raw.length > 80) throw new Error('schedule is too long')
107
+ const phrase = phraseToCron(raw)
108
+ if (phrase) return phrase
109
+ if (!isCron(raw)) throw new Error('use 5-field cron, or a phrase like "daily at 9" or "weekdays at 9:30"')
110
+ return raw
111
+ }
112
+
113
+ /** The cron as stored, or null when it is not already in canonical form. Missing is empty. */
114
+ export function canonicalCron (value) {
115
+ if (value == null || value === '') return ''
116
+ if (typeof value !== 'string' || value.length > 80) return null
117
+ try {
118
+ const parsed = parseSchedule(value)
119
+ return parsed === value ? value : null
120
+ } catch {
121
+ return null
122
+ }
123
+ }
124
+
125
+ function clockLabel (hour, minute) {
126
+ const h = Number(hour)
127
+ const m = Number(minute)
128
+ const ap = h >= 12 ? 'pm' : 'am'
129
+ const h12 = h % 12 || 12
130
+ return m === 0 ? `${h12}${ap}` : `${h12}:${String(m).padStart(2, '0')}${ap}`
131
+ }
132
+
133
+ /** English for the schedules the card knows. Anything else is the cron itself. */
134
+ export function cronToText (cron) {
135
+ const c = String(cron || '').trim()
136
+ if (!c) return ''
137
+ const parts = c.split(/\s+/)
138
+ if (parts.length !== 5 || !isCron(c)) return c
139
+ const [min, hour, dom, mon, dow] = parts
140
+ if (mon !== '*') return c
141
+ if (c === '* * * * *') return 'Every minute'
142
+ const everyMin = /^\*\/(\d+)$/.exec(min)
143
+ if (everyMin && hour === '*' && dom === '*' && dow === '*') return `Every ${everyMin[1]} minutes`
144
+ if (min === '0' && hour === '*' && dom === '*' && dow === '*') return 'Every hour'
145
+ const everyHour = /^\*\/(\d+)$/.exec(hour)
146
+ if (min === '0' && everyHour && dom === '*' && dow === '*') return `Every ${everyHour[1]} hours`
147
+ if (!/^\d+$/.test(min) || !/^\d+$/.test(hour)) return c
148
+ const when = clockLabel(hour, min)
149
+ if (dom === '*' && dow === '*') return `Every day at ${when}`
150
+ if (dom === '*' && dow === '1-5') return `Weekdays at ${when}`
151
+ if (dom === '*' && dow === '0,6') return `Weekends at ${when}`
152
+ if (dom === '1' && dow === '*') return `Monthly on the 1st at ${when}`
153
+ if (dom === '*' && /^\d$/.test(dow)) return `Every ${DAYS[Number(dow)]} at ${when}`
154
+ return c
155
+ }
156
+
@@ -0,0 +1,255 @@
1
+ // Secrets (docs/superpowers/specs/2026-10-18-secrets.md): the rules every part shares, the
2
+ // accounts API, the vault, `quilt run`, the app and the website. Pure, no Node built-ins, so the
3
+ // app's page and the website import it too. Nothing here ever sees a value except
4
+ // applyCredential (the vault, as it forwards a request) and parseDotenv (the app, as it sends an
5
+ // .env file's values straight to the vault).
6
+
7
+ export const SCOPES = ['org', 'workspace', 'session']
8
+ export const WHO = ['agents', 'people']
9
+ export const MAX_VALUE_BYTES = 16 * 1024
10
+ export const MAX_ENTRIES = 100
11
+ // What `quilt run` puts where a value would be. SDKs that insist on a key get one.
12
+ export const STAND_IN = 'quilt'
13
+
14
+ const NAME = /^[A-Z_][A-Z0-9_]{0,63}$/
15
+ export const BAD_NAME = 'A secret name is capital letters, digits and underscores, like STRIPE_KEY (at most 64).'
16
+
17
+ /** A secret's name, or an error saying what one looks like. */
18
+ export function cleanSecretName (value) {
19
+ const s = String(value ?? '').trim()
20
+ if (!NAME.test(s)) throw new Error(BAD_NAME)
21
+ return s
22
+ }
23
+
24
+ const HOST_LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/
25
+ const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/
26
+ const INSIDE = /(^|\.)(localhost|local|internal|localdomain|home|lan|intranet|corp|private|test|invalid|example|onion|arpa)$/
27
+ export const NOT_HTTPS = 'Only HTTPS services for now: Postgres and other database secrets are not supported yet.'
28
+
29
+ /**
30
+ * The one host a secret is sent to: `api.stripe.com`, from that or from `https://api.stripe.com/`.
31
+ * A port is allowed (`api.example.com:8443`). Never an IP address, a single-label name or an
32
+ * internal one (`localhost`, `*.internal`): the vault would otherwise reach inside its own
33
+ * network. `allowLocal` is for tests only (a stand-in upstream on 127.0.0.1).
34
+ */
35
+ export function cleanUpstream (value, { allowLocal = false } = {}) {
36
+ let s = String(value ?? '').trim().toLowerCase()
37
+ if (!s) throw new Error('Say where the secret is sent: its service\'s host, like api.stripe.com.')
38
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//.exec(s)
39
+ if (scheme) {
40
+ if (scheme[1] !== 'https' && !(allowLocal && scheme[1] === 'http')) throw new Error(NOT_HTTPS)
41
+ s = s.slice(scheme[0].length)
42
+ }
43
+ s = s.replace(/\/+$/, '')
44
+ if (/[/?#@\s\\]/.test(s)) throw new Error('Just the host, like api.stripe.com: no path.')
45
+ const m = /^(\[[^\]]*\]|[^:]+)(?::(\d{1,5}))?$/.exec(s)
46
+ if (!m) throw new Error('That is not a host name.')
47
+ const [, host, port] = m
48
+ if (port !== undefined && (Number(port) < 1 || Number(port) > 65535)) throw new Error('That port is not right.')
49
+ if (allowLocal && (host === '127.0.0.1' || host === 'localhost')) return port ? `${host}:${Number(port)}` : host
50
+ if (host.startsWith('[') || IPV4.test(host)) throw new Error('A host name, not an IP address.')
51
+ const labels = host.split('.')
52
+ if (labels.length < 2 || host.length > 253 || !labels.every((l) => HOST_LABEL.test(l)) || /^\d+$/.test(labels.at(-1))) throw new Error('That is not a host name.')
53
+ if (INSIDE.test(host)) throw new Error('That host is inside a private network; secrets go to public services only.')
54
+ return port && Number(port) !== 443 ? `${host}:${Number(port)}` : host
55
+ }
56
+
57
+ // How the vault attaches the value. `{value}` is the value; `{basic}` is base64 of "value:"
58
+ // (HTTP Basic with the key as the user name).
59
+ export const FORMATS = {
60
+ bearer: { in: 'header', name: 'authorization', template: 'Bearer {value}', label: 'Authorization: Bearer' },
61
+ 'x-api-key': { in: 'header', name: 'x-api-key', template: '{value}', label: 'x-api-key header' },
62
+ basic: { in: 'header', name: 'authorization', template: 'Basic {basic}', label: 'Basic (key as user)' },
63
+ query: { in: 'query', name: 'key', template: '{value}', label: 'Query ?key=' },
64
+ // A webhook URL: the value (the URL, or its path) starts the request's path.
65
+ path: { in: 'path', name: '', template: '{value}', label: 'In the path (webhook URL)' }
66
+ }
67
+ export const FORMAT_IDS = Object.keys(FORMATS)
68
+ const TOKEN = /^[a-z0-9!#$%&'*+.^_`|~-]{1,64}$/
69
+ const PARAM = /^[A-Za-z0-9_.~-]{1,64}$/
70
+ // Headers a secret may not be put in: they belong to the connection, not to the service.
71
+ const RESERVED = new Set(['host', 'connection', 'content-length', 'transfer-encoding', 'te', 'upgrade', 'keep-alive', 'proxy-authorization', 'proxy-connection', 'trailer', 'accept-encoding', 'content-encoding'])
72
+ export const BAD_FORMAT = 'Pick how the secret is sent: Authorization: Bearer, an x-api-key header, Basic, a query parameter, in the path, or a header of your own with {value} in it.'
73
+
74
+ /**
75
+ * A credential format: a preset's id, or { in: 'header'|'query', name, template } with
76
+ * `{value}` (or `{basic}`) exactly once. Answers { in, name, template }.
77
+ */
78
+ export function cleanFormat (value) {
79
+ if (typeof value === 'string' && FORMATS[value]) { const { label, ...f } = FORMATS[value]; return f }
80
+ if (!value || typeof value !== 'object') throw new Error(BAD_FORMAT)
81
+ const where = ['query', 'header', 'path'].includes(value.in) ? value.in : null
82
+ const name = String(value.name ?? '').trim()
83
+ const template = String(value.template ?? '{value}')
84
+ if (!where) throw new Error(BAD_FORMAT)
85
+ if (where === 'path') return { in: 'path', name: '', template: '{value}' }
86
+ if (where === 'header' ? !TOKEN.test(name.toLowerCase()) || RESERVED.has(name.toLowerCase()) : !PARAM.test(name)) throw new Error(BAD_FORMAT)
87
+ const holes = (template.match(/\{(value|basic)\}/g) || []).length
88
+ if (holes !== 1 || template.length > 200 || /[\r\n\0]/.test(template) || /\{(?!value\}|basic\})/.test(template)) throw new Error(BAD_FORMAT)
89
+ return { in: where, name: where === 'header' ? name.toLowerCase() : name, template }
90
+ }
91
+
92
+ /** A short label for a format, for the table: "Authorization: Bearer", "?key=". */
93
+ export function formatLabel (f) {
94
+ for (const p of Object.values(FORMATS)) if (p.in === f?.in && p.name === f?.name && p.template === f?.template) return p.label
95
+ if (!f) return ''
96
+ if (f.in === 'path') return FORMATS.path.label
97
+ return f.in === 'query' ? `?${f.name}=` : `${f.name}: ${f.template.replace(/\{(value|basic)\}/, '…')}`
98
+ }
99
+
100
+ /** The preset id a format is, or '' for one of a person's own. */
101
+ export function formatId (f) {
102
+ return FORMAT_IDS.find((id) => FORMATS[id].in === f?.in && FORMATS[id].name === f?.name && FORMATS[id].template === f?.template) || ''
103
+ }
104
+
105
+ function base64 (s) {
106
+ const bytes = new TextEncoder().encode(s)
107
+ let bin = ''
108
+ for (const b of bytes) bin += String.fromCharCode(b)
109
+ return btoa(bin)
110
+ }
111
+
112
+ /**
113
+ * What the vault puts on the request: { header: [name, text] }, { query: [name, text] }, or
114
+ * { path: text } (to put before the request's own path; a whole https URL is cut to its path).
115
+ */
116
+ export function applyCredential (format, value) {
117
+ const f = cleanFormat(format)
118
+ if (f.in === 'path') return { path: String(value).replace(/^https:\/\/[^/]+/i, '').replace(/^\/+/, '') }
119
+ const text = f.template.replace('{value}', () => value).replace('{basic}', () => base64(`${value}:`))
120
+ return f.in === 'query' ? { query: [f.name, text] } : { header: [f.name, text] }
121
+ }
122
+
123
+ /** The forms of a value the vault masks in an answer: as sent, and as the format spelled it. */
124
+ export function maskForms (format, value) {
125
+ const out = new Set([String(value)])
126
+ try {
127
+ const c = applyCredential(format, value)
128
+ const text = c.path !== undefined ? c.path : (c.header || c.query)[1]
129
+ if (text !== value) out.add(text)
130
+ if (/\{basic\}/.test(cleanFormat(format).template)) out.add(base64(`${value}:`))
131
+ } catch {}
132
+ try { const enc = encodeURIComponent(value); if (enc !== value) out.add(enc) } catch {}
133
+ return [...out].filter((s) => s.length >= 4)
134
+ }
135
+
136
+ // SDKs that read a base URL from the environment, by host: [variable, path after the host].
137
+ export const KNOWN_BASE_URLS = {
138
+ 'api.openai.com': [['OPENAI_BASE_URL', 'v1']],
139
+ 'api.anthropic.com': [['ANTHROPIC_BASE_URL', '']],
140
+ 'api.groq.com': [['GROQ_BASE_URL', '']]
141
+ }
142
+
143
+ /** The variable `quilt run` gives a secret's gateway in: STRIPE_KEY → STRIPE_BASE_URL. */
144
+ export function baseUrlVar (name) {
145
+ const m = /^(.+?)_(API_KEY|SECRET_KEY|ACCESS_TOKEN|KEY|SECRET|TOKEN)$/.exec(name)
146
+ return `${m ? m[1] : name}_BASE_URL`
147
+ }
148
+
149
+ /** Every variable `quilt run` sets for a secret: its name (the stand-in) and its base URLs. */
150
+ export function envVarsFor (name, upstream) {
151
+ return [name, baseUrlVar(name), ...(KNOWN_BASE_URLS[String(upstream || '').toLowerCase()] || []).map(([v]) => v)]
152
+ .filter((v, i, a) => a.indexOf(v) === i)
153
+ }
154
+
155
+ /**
156
+ * The environment `quilt run` gives a command: for each secret, the stand-in and the gateway
157
+ * (`<vault>/g/<token>/<NAME>/`), plus the conventional base URL of a known service (the first
158
+ * secret for that service wins). Never a value: there is none here.
159
+ */
160
+ export function runEnv (secrets, { vault, token }) {
161
+ const base = String(vault).replace(/\/+$/, '')
162
+ const env = {}
163
+ const taken = new Set()
164
+ for (const s of secrets) {
165
+ const gateway = `${base}/g/${token}/${s.name}/`
166
+ env[s.name] = STAND_IN
167
+ const own = baseUrlVar(s.name)
168
+ if (own !== s.name) { env[own] = gateway; taken.add(own) }
169
+ }
170
+ for (const s of secrets) {
171
+ const gateway = `${base}/g/${token}/${s.name}/`
172
+ for (const [v, p] of KNOWN_BASE_URLS[String(s.upstream || '').toLowerCase()] || []) {
173
+ if (taken.has(v) || secrets.some((x) => x.name === v)) continue
174
+ env[v] = p ? `${gateway}${p}` : gateway.replace(/\/$/, '')
175
+ taken.add(v)
176
+ }
177
+ }
178
+ return env
179
+ }
180
+
181
+ /**
182
+ * An .env file's names and values, in order (the last of a repeated name wins). Understands
183
+ * `export`, comments, single quotes (as is), double quotes (\n, \t, \", \\) and a value that
184
+ * runs on to the closing quote across lines.
185
+ */
186
+ export function parseDotenv (text) {
187
+ const out = new Map()
188
+ const src = String(text ?? '').replace(/\r\n?/g, '\n')
189
+ const re = /^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_.-]*)[ \t]*=[ \t]*(?:"((?:\\[\s\S]|[^"\\])*)"|'([^']*)'|([^\n]*))[ \t]*(?:#[^\n]*)?$/gm
190
+ let m
191
+ while ((m = re.exec(src))) {
192
+ const [, name, dq, sq, bare] = m
193
+ let value
194
+ if (dq !== undefined) value = dq.replace(/\\(.)/g, (_, c) => ({ n: '\n', r: '\r', t: '\t' }[c] ?? c))
195
+ else if (sq !== undefined) value = sq
196
+ else value = bare.replace(/[ \t]+#.*$/, '').trim()
197
+ out.delete(name)
198
+ out.set(name, value)
199
+ }
200
+ return [...out].map(([name, value]) => ({ name, value }))
201
+ }
202
+
203
+ // Where well-known keys go, by the start of their name: [prefix, host, format].
204
+ const GUESSES = [
205
+ ['STRIPE', 'api.stripe.com', 'bearer'],
206
+ ['OPENAI', 'api.openai.com', 'bearer'],
207
+ ['ANTHROPIC', 'api.anthropic.com', 'x-api-key'],
208
+ ['CLAUDE', 'api.anthropic.com', 'x-api-key'],
209
+ ['GROQ', 'api.groq.com', 'bearer'],
210
+ ['MISTRAL', 'api.mistral.ai', 'bearer'],
211
+ ['OPENROUTER', 'openrouter.ai', 'bearer'],
212
+ ['XAI', 'api.x.ai', 'bearer'],
213
+ ['GROK', 'api.x.ai', 'bearer'],
214
+ ['GITHUB', 'api.github.com', 'bearer'],
215
+ ['GH_', 'api.github.com', 'bearer'],
216
+ ['SENDGRID', 'api.sendgrid.com', 'bearer'],
217
+ ['RESEND', 'api.resend.com', 'bearer'],
218
+ ['POSTMARK', 'api.postmarkapp.com', 'bearer'],
219
+ ['MAILGUN', 'api.mailgun.net', 'basic'],
220
+ ['TWILIO', 'api.twilio.com', 'basic'],
221
+ ['REPLICATE', 'api.replicate.com', 'bearer'],
222
+ ['HUGGINGFACE', 'huggingface.co', 'bearer'],
223
+ ['HF_', 'huggingface.co', 'bearer'],
224
+ ['CLOUDFLARE', 'api.cloudflare.com', 'bearer'],
225
+ ['VERCEL', 'api.vercel.com', 'bearer'],
226
+ ['NETLIFY', 'api.netlify.com', 'bearer'],
227
+ ['LINEAR', 'api.linear.app', 'bearer'],
228
+ ['NOTION', 'api.notion.com', 'bearer'],
229
+ ['SLACK', 'slack.com', 'bearer'],
230
+ ['DEEPSEEK', 'api.deepseek.com', 'bearer'],
231
+ ['ELEVENLABS', 'api.elevenlabs.io', 'bearer'],
232
+ ['PERPLEXITY', 'api.perplexity.ai', 'bearer']
233
+ ]
234
+
235
+ /**
236
+ * What the app suggests for an .env entry: { upstream, format } when the name or value says
237
+ * (an https URL's host), { skip: reason } for a value that can't go through the gateway (a
238
+ * database URL), or {} when the person has to say.
239
+ */
240
+ export function guessUpstream (name, value = '') {
241
+ const v = String(value || '').trim()
242
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(v)
243
+ if (scheme && scheme[1].toLowerCase() !== 'https') return { skip: NOT_HTTPS }
244
+ // A whole URL (a webhook): sent to its host, with the URL's path as the secret part.
245
+ if (scheme) { try { return { upstream: cleanUpstream(new URL(v).host), format: 'path' } } catch { return {} } }
246
+ const n = String(name).toUpperCase()
247
+ const g = GUESSES.find(([p]) => n.startsWith(p))
248
+ return g ? { upstream: g[1], format: g[2] } : {}
249
+ }
250
+
251
+ /** A digest as the table shows it: a91e…07c2. */
252
+ export function shortDigest (d) {
253
+ const s = String(d || '')
254
+ return s.length > 8 ? `${s.slice(0, 4)}…${s.slice(-4)}` : s
255
+ }