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,162 @@
1
+ // The Secrets card (mockup docs/mockups/2026-10-18-secrets.html, option B), as HTML from the
2
+ // API's view of one scope and the card's own state. Pure: secrets.js draws it and binds it in
3
+ // the app; tests read it. A value never appears here: the API has none to give, and the value
4
+ // a person types stays in its input (secrets.js sets it back after a redraw, never in markup).
5
+ import { FORMATS, FORMAT_IDS, formatLabel } from './secret-rules.js'
6
+
7
+ export const LEVEL_WORDS = { org: 'Team', workspace: 'Workspace', session: 'Session' }
8
+ const ORDER = ['org', 'workspace', 'session']
9
+ const LOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>'
10
+ const AVATAR_COLORS = ['#24706b', '#8a5a08', '#5b4bb7', '#2f5d8a', '#a8263e', '#2f6b40']
11
+
12
+ export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
13
+
14
+ /** "2 min ago", "1 h ago", "today", a date; "never" for nothing. */
15
+ export function usedText (ts, now = Date.now()) {
16
+ if (!ts) return 'never'
17
+ const s = Math.max(0, Math.round((now - ts) / 1000))
18
+ if (s < 60) return 'just now'
19
+ if (s < 3600) return `${Math.round(s / 60)} min ago`
20
+ if (s < 6 * 3600) return `${Math.round(s / 3600)} h ago`
21
+ if (new Date(ts).toDateString() === new Date(now).toDateString()) return 'today'
22
+ return new Date(ts).toLocaleDateString([], { day: 'numeric', month: 'short' })
23
+ }
24
+
25
+ /** The levels a place has, Team first: [{ type, id, label }]. */
26
+ export function levelTabs (view) {
27
+ return [...(view?.levels || [])].sort((a, b) => ORDER.indexOf(a.type) - ORDER.indexOf(b.type)).map((l) => ({ type: l.type, id: l.id, label: LEVEL_WORDS[l.type], name: l.name }))
28
+ }
29
+
30
+ /** What the inherit switch says: "Workspace and team secrets here", "Team secrets here". */
31
+ export function inheritLabel (view) {
32
+ const above = (view.levels || []).slice(1).map((l) => l.type)
33
+ const words = above.map((t) => (t === 'org' ? 'team' : 'workspace'))
34
+ if (!words.length) return ''
35
+ const list = words.length === 1 ? words[0] : `${words.slice(0, -1).join(', ')} and ${words.at(-1)}`
36
+ return `${list[0].toUpperCase()}${list.slice(1)} secrets here`
37
+ }
38
+
39
+ /** "X-Token: Token {value}" as a format, or null. */
40
+ export function parseCustomFormat (text) {
41
+ const m = /^\s*([A-Za-z0-9-]{1,64})\s*:\s*(.*\{value\}.*)$/.exec(String(text || ''))
42
+ return m ? { in: 'header', name: m[1].toLowerCase(), template: m[2].trim() } : null
43
+ }
44
+
45
+ const sw = (on, attrs, label, { disabled = false, small = false } = {}) =>
46
+ `<button type="button" class="sx-sw${small ? ' sm' : ''}${on ? ' on' : ''}" role="switch" aria-checked="${on}" ${attrs}${disabled ? ' disabled' : ''}><i aria-hidden="true"></i><span>${label}</span></button>`
47
+
48
+ const formatOptions = (selected) => [...FORMAT_IDS.map((id) => `<option value="${id}"${selected === id ? ' selected' : ''}>${esc(FORMATS[id].label)}</option>`), `<option value="custom"${selected === 'custom' ? ' selected' : ''}>A header of your own…</option>`].join('')
49
+
50
+ function avatars (view) {
51
+ const s = view.settings
52
+ const list = s.agents === 'all' ? view.agentChoices : view.agentChoices.filter((a) => s.agents.includes(a.id))
53
+ if (!s.agentsOn) return '<span class="sx-faint">off</span>'
54
+ if (s.agents === 'all') return `<span class="sx-faint">All agents${list.length ? ` (${list.length})` : ''}</span>`
55
+ if (!list.length) return '<span class="sx-faint">none chosen</span>'
56
+ return `<span class="sx-who">${list.slice(0, 5).map((a, i) => `<span class="sx-av" title="${esc(a.name)}" style="background:${AVATAR_COLORS[i % AVATAR_COLORS.length]}">${esc([...a.name][0] || '?')}</span>`).join('')}${list.length > 5 ? `<span class="sx-faint">+${list.length - 5}</span>` : ''}</span>`
57
+ }
58
+
59
+ function inheritRight (view) {
60
+ const s = view.settings
61
+ if (s.inheritOff) return `<span class="sx-faint">off${s.inheritOffAt ? ` since ${new Date(s.inheritOffAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}` : ''}${s.inheritOffBy ? ` by ${esc(s.inheritOffBy)}` : ''}</span>`
62
+ const parents = (view.levels || []).slice(1)
63
+ const n = (view.inherited || []).length
64
+ return `<span class="sx-faint">${esc(parents.map((l) => l.name).filter(Boolean).join(' · '))}${parents.length ? ' · ' : ''}${n} secret${n === 1 ? '' : 's'}</span>`
65
+ }
66
+
67
+ function chooseHtml (view, ui) {
68
+ const sel = ui.chooseSel
69
+ const all = sel === 'all'
70
+ return `<div class="sx-choose" data-choose-panel>
71
+ <label class="sx-check"><input type="radio" name="sx-choose" value="all" ${all ? 'checked' : ''} data-choose-all> All agents here, and ones added later</label>
72
+ <label class="sx-check"><input type="radio" name="sx-choose" value="some" ${all ? '' : 'checked'} data-choose-some> Only these:</label>
73
+ <div class="sx-choose-list">${view.agentChoices.length ? view.agentChoices.map((a) => `<label class="sx-check"><input type="checkbox" data-choose-agent="${esc(a.id)}" ${!all && sel.includes(a.id) ? 'checked' : ''} ${all ? 'disabled' : ''}> ${esc(a.name)}</label>`).join('') : '<span class="sx-faint">No agents here yet.</span>'}</div>
74
+ <span class="sx-choose-acts"><button type="button" class="btn sm ghost" data-choose-cancel>Cancel</button><button type="button" class="btn sm primary" data-choose-save>Save</button></span>
75
+ </div>`
76
+ }
77
+
78
+ function importHtml (ui) {
79
+ const im = ui.import
80
+ if (im.loading) return '<div class="sx-import"><span class="sx-faint">Looking for .env files…</span></div>'
81
+ if (im.done) {
82
+ return `<div class="sx-import"><div class="sx-import-done">
83
+ <span>Saved ${im.done.saved.length} secret${im.done.saved.length === 1 ? '' : 's'} from <code>${esc(im.done.rel)}</code>. Your project reaches them through <code>quilt run</code> now${im.done.deleted ? ', and the file is deleted.' : ': the file is no longer needed.'}</span>
84
+ <span class="sx-grow"></span>${im.done.deleted ? '' : `<button type="button" class="btn sm danger" data-import-delete>Delete ${esc(im.done.rel)}</button><button type="button" class="btn sm ghost" data-import-close>Keep it</button>`}${im.done.deleted ? '<button type="button" class="btn sm ghost" data-import-close>Done</button>' : ''}
85
+ </div></div>`
86
+ }
87
+ if (!im.files.length) return `<div class="sx-import"><div class="sx-import-done"><span>${esc(im.empty || 'No .env files in this session\'s folder.')}</span><span class="sx-grow"></span><button type="button" class="btn sm ghost" data-import-close>Close</button></div></div>`
88
+ const f = im.files[im.fileIdx]
89
+ const picked = im.rows.filter((r) => r.pick && !r.skip).length
90
+ return `<div class="sx-import">
91
+ <div class="sx-import-head"><b>Import</b>${im.files.length > 1 ? `<select class="input sx-in" data-import-file aria-label=".env file">${im.files.map((x, i) => `<option value="${i}"${i === im.fileIdx ? ' selected' : ''}>${esc(x.rel)}</option>`).join('')}</select>` : `<code>${esc(f.rel)}</code>`}<span class="sx-faint">Names and where each goes. The values go from this computer straight to Quilt's vault.</span></div>
92
+ <div class="sx-import-rows">${im.rows.map((r, i) => r.skip
93
+ ? `<div class="sx-import-row skip"><span class="sx-name">${esc(r.name)}</span><span class="sx-faint">${esc(r.skip)}</span></div>`
94
+ : `<div class="sx-import-row"><label class="sx-check"><input type="checkbox" data-import-pick="${i}" ${r.pick ? 'checked' : ''}><span class="sx-name">${esc(r.name)}</span></label><input class="input sx-in mono" data-import-host="${i}" value="${esc(r.upstream)}" placeholder="sends to: api.example.com" aria-label="Where ${esc(r.name)} is sent"><select class="input sx-in" data-import-format="${i}" aria-label="How ${esc(r.name)} is sent">${formatOptions(r.format)}</select>${sw(r.who !== 'people', `data-import-who="${i}"`, r.who === 'people' ? 'People only' : 'Agents', { small: true })}</div>`).join('')}</div>
95
+ <div class="sx-import-foot"><span class="sx-grow"></span><button type="button" class="btn sm ghost" data-import-close>Cancel</button><button type="button" class="btn sm primary" data-import-save ${picked ? '' : 'disabled'}>Save ${picked} secret${picked === 1 ? '' : 's'}</button></div>
96
+ </div>`
97
+ }
98
+
99
+ function rowHtml (r, view, ui, inherited) {
100
+ const manage = view.canManage
101
+ const usedNow = inherited && r.off ? '—' : usedText(r.usedAt, ui.now)
102
+ // In a narrow card the digest and when it was used go under the name (their columns hide).
103
+ const name = `<td class="sx-name">${esc(r.name)}${inherited ? ` <span class="sx-lvl ${r.level}${r.overridden ? ' over' : ''}">${LEVEL_WORDS[r.level]}</span>` : ''}<span class="sx-sub">${esc(r.digest)} · ${inherited && r.off ? 'off here' : usedNow === 'never' ? 'never used' : `used ${esc(usedNow)}`}</span></td>`
104
+ const who = `<td class="sx-who-td">${sw(r.who !== 'people', inherited ? 'data-who-view' : `data-who="${esc(r.name)}"`, r.who === 'people' ? 'People only' : 'Agents', { small: true, disabled: inherited || !manage })}</td>`
105
+ const used = `<td class="sx-hide-m"><span class="sx-status${r.usedAt && !(inherited && r.off) ? '' : ' unused'}">${esc(usedNow)}</span></td>`
106
+ let act = ''
107
+ if (!inherited && manage) {
108
+ act = ui.confirmDelete === r.name
109
+ ? ''
110
+ : `<button type="button" class="btn sm" data-replace="${esc(r.name)}">Replace</button><button type="button" class="btn sm danger" data-delete="${esc(r.name)}">Delete</button>`
111
+ } else if (inherited && r.off) act = '<span class="sx-faint">off here</span>'
112
+ else if (inherited && manage) act = `<button type="button" class="btn sm" data-override="${esc(r.name)}">Override</button>`
113
+ // Delete asks in place, on a line of its own under the secret.
114
+ const confirm = !inherited && manage && ui.confirmDelete === r.name
115
+ ? `<tr class="sx-confirm-row"><td colspan="6"><div class="sx-confirm-in"><span class="sx-confirm">Delete ${esc(r.name)}? Nothing can use it after, and its value is gone for good.</span><span class="sx-confirm-acts"><button type="button" class="btn sm ghost" data-delete-no>Keep</button><button type="button" class="btn sm danger" data-delete-yes="${esc(r.name)}">Delete</button></span></div></td></tr>`
116
+ : ''
117
+ return `<tr class="${inherited && r.off ? 'sx-off' : ''}${ui.flash === r.name ? ' sx-flash' : ''}">${name}<td class="sx-hide-m mono sx-faint">${esc(r.digest)}</td><td class="sx-host-td"><span class="sx-host" title="${esc(r.formatLabel || formatLabel(r.format))}">${esc(r.upstream)}</span></td>${who}${used}<td class="sx-act"><span class="sx-acts">${act}</span></td></tr>${confirm}`
118
+ }
119
+
120
+ /** The whole card for `view` (GET /v1/secrets/:scope/:id) and the card's state `ui`. */
121
+ export function secretsCardHtml (view, ui = {}) {
122
+ ui = { now: Date.now(), tabs: [], form: {}, ...ui }
123
+ const tabs = ui.tabs.length ? ui.tabs : levelTabs(view)
124
+ const cur = view ? view.scope.type : ui.cur?.type
125
+ const seg = tabs.length > 1 ? `<span class="sx-seg" role="tablist" aria-label="Secrets for">${tabs.map((t) => `<button type="button" role="tab" aria-selected="${t.type === cur}" class="${t.type === cur ? 'on' : ''}" data-level="${t.type}" data-level-id="${esc(t.id)}">${t.label}</button>`).join('')}</span>` : `<span class="sx-lvl ${cur}">${LEVEL_WORDS[cur] || ''}</span>`
126
+ const head = `<div class="sx-head">${LOCK}<span class="sx-title">Secrets</span>${seg}${view && view.scope.name ? `<span class="sx-faint sx-scope">${esc(view.scope.name)}</span>` : ''}<span class="sx-grow"></span>${view && view.canManage && ui.canImport ? `<button type="button" class="btn sm" data-import ${ui.import ? 'disabled' : ''}>Import .env</button>` : ''}</div>`
127
+ if (!view) return `<div class="sx">${head}<div class="sx-empty">${ui.error ? `<span class="sx-err">${esc(ui.error)}</span>` : '<span class="sx-faint">Loading…</span>'}</div></div>`
128
+
129
+ const manage = view.canManage
130
+ const inheritable = (view.levels || []).length > 1
131
+ const s = view.settings
132
+ const controls = `<div class="sx-controls">
133
+ ${inheritable ? `<div>${sw(!s.inheritOff, 'data-inherit', `<b>${esc(inheritLabel(view))}</b>`, { disabled: !manage })}<span class="sx-grow"></span>${inheritRight(view)}</div>` : ''}
134
+ <div>${sw(s.agentsOn, 'data-agents-on', '<b>Agents can use secrets</b>', { disabled: !manage })}<span class="sx-grow"></span>${avatars(view)}${manage && s.agentsOn ? `<button type="button" class="btn sm ghost" data-choose ${ui.choose ? 'aria-expanded="true"' : ''}>Choose</button>` : ''}</div>
135
+ </div>`
136
+ const off = s.inheritOff && view.offCount ? `<div class="sx-off-note"><span>${view.offCount} ${esc(inheritLabel(view).replace(/ here$/, '').replace(/^./, (c) => c.toLowerCase()))} ${view.offCount === 1 ? 'is' : 'are'} off ${view.scope.type === 'session' ? 'in this session' : 'in this workspace'}. Nothing here can use them.</span><span class="sx-grow"></span>${manage ? '<button type="button" class="btn sm" data-inherit-on>Turn back on</button>' : ''}</div>` : ''
137
+ const f = ui.form
138
+ const mode = ui.mode && ui.mode.name ? ui.mode : null
139
+ const add = manage ? `<form class="sx-new" data-add autocomplete="off">
140
+ <input class="input sx-in mono" name="name" value="${esc(f.name || '')}" placeholder="NAME" aria-label="Name" spellcheck="false" autocapitalize="characters" ${mode && mode.kind === 'replace' ? 'readonly' : ''}>
141
+ <input class="input sx-in mono" name="value" type="password" placeholder="${mode && mode.kind === 'replace' ? `new value for ${esc(mode.name)}` : 'value'}" aria-label="Value" autocomplete="new-password" spellcheck="false">
142
+ <input class="input sx-in mono" name="upstream" value="${esc(f.upstream || '')}" placeholder="sends to: api.stripe.com" aria-label="Where it is sent (its one host)" spellcheck="false">
143
+ <select class="input sx-in" name="format" aria-label="How it is sent">${formatOptions(f.format || 'bearer')}</select>
144
+ ${f.format === 'custom' ? `<input class="input sx-in mono sx-custom" name="custom" value="${esc(f.custom || '')}" placeholder="X-Token: Token {value}" aria-label="Your header, with {value}">` : ''}
145
+ <span class="sx-new-acts">${mode ? '<button type="button" class="btn sm ghost" data-add-cancel>Cancel</button>' : ''}<button type="submit" class="btn sm primary" ${ui.busy ? 'disabled' : ''}>${mode && mode.kind === 'replace' ? 'Replace' : mode && mode.kind === 'override' ? 'Override' : 'Add'}</button></span>
146
+ </form>` : ''
147
+ const msg = ui.msg ? `<div class="sx-msg ${ui.msg.kind || ''}" role="status">${esc(ui.msg.text)}</div>` : ''
148
+ const rows = [...view.secrets.map((r) => rowHtml(r, view, ui, false)), ...(view.inherited || []).map((r) => rowHtml(r, view, ui, true))]
149
+ const table = rows.length
150
+ ? `<div class="sx-scroll"><table class="sx-tbl"><thead><tr><th>Name</th><th class="sx-hide-m">Digest</th><th>Sent to</th><th>Who can use</th><th class="sx-hide-m">Used</th><th><span class="sx-sr">Actions</span></th></tr></thead><tbody>${rows.join('')}</tbody></table></div>`
151
+ : `<div class="sx-empty"><span class="sx-faint">No secrets ${view.scope.type === 'org' ? 'for the team' : `in this ${view.scope.type}`} yet.${manage ? ' Add one above: its value goes straight to Quilt\'s vault and is never shown again, to anyone.' : ''}</span></div>`
152
+ const foot = `<div class="sx-foot sx-faint">Commands use them through <code>quilt run -- &lt;command&gt;</code>; nobody can read a value back. ${manage ? 'Replace or delete only.' : ''}</div>`
153
+ return `<div class="sx">${head}${controls}${ui.choose ? chooseHtml(view, ui) : ''}${off}${ui.import ? importHtml(ui) : ''}${add}${msg}${table}${foot}</div>`
154
+ }
155
+
156
+ /** The inline bar asking a person to allow a people-only secret for one run of a command. */
157
+ export function asksBarHtml (asks) {
158
+ if (!asks || !asks.length) return ''
159
+ const a = asks[0]
160
+ const names = a.names.join(', ')
161
+ return `<div class="ask-bar-in" role="alert">${LOCK}<span class="ask-text"><b>Allow ${esc(names)}</b> (people only) for one run of <code>${esc(a.label || 'a command')}</code>${a.sessionName ? ` in ${esc(a.sessionName)}` : ''}?</span>${asks.length > 1 ? `<span class="ask-more">+${asks.length - 1} more</span>` : ''}<span class="sx-grow"></span><button type="button" class="btn sm ghost ask-deny" data-ask-deny="${esc(a.runId)}">Deny</button><button type="button" class="btn sm ask-allow" data-ask-allow="${esc(a.runId)}">Allow</button></div>`
162
+ }
@@ -0,0 +1,283 @@
1
+ // The Secrets card in the app (Session settings → Secrets, and a workspace's page), and the bar
2
+ // that asks a person to allow a people-only secret for one run. The HTML is secrets-view.js's;
3
+ // this file loads, binds and saves. A typed value lives in its input and in `typed` here until it
4
+ // goes to the app's server, which sends it straight to the vault; it is never put in markup.
5
+ import { api, toast } from './common.js'
6
+ import { secretsCardHtml, levelTabs, parseCustomFormat, asksBarHtml } from './secrets-view.js'
7
+ import { cleanSecretName, FORMAT_IDS, formatId } from './secret-rules.js'
8
+
9
+ const views = new Map() // `${type}:${id}` -> the last view, so a redraw shows it at once
10
+ const urlOf = (type, id, rest = '') => `/api/secrets/${type}/${encodeURIComponent(id)}${rest}`
11
+
12
+ /**
13
+ * Draws the card into `root` for one scope ({ type, id }: a session's room, a workspace's id, a
14
+ * team's slug). `importFrom` says where .env files may be read from ({ sessionId } or
15
+ * { workspaceId }); without it there is no Import .env. Answers { refresh, destroy }.
16
+ */
17
+ export function mountSecrets (root, { type, id, importFrom = null }) {
18
+ const ui = { tabs: [], cur: { type, id }, form: { format: 'bearer' }, mode: null, choose: false, chooseSel: 'all', import: null, msg: null, busy: false, confirmDelete: null, canImport: !!importFrom, error: '', flash: '' }
19
+ let view = views.get(`${type}:${id}`) || null
20
+ if (view) ui.tabs = levelTabs(view)
21
+ let typed = ''
22
+ let alive = true
23
+ let fresh = false
24
+
25
+ /** What the add row holds now (the value only in memory). */
26
+ function capture () {
27
+ const f = root.querySelector('[data-add]')
28
+ if (!f) return
29
+ ui.form = { ...ui.form, name: f.elements.name.value, upstream: f.elements.upstream.value, format: f.elements.format.value, custom: f.elements.custom ? f.elements.custom.value : ui.form.custom }
30
+ typed = f.elements.value.value
31
+ }
32
+
33
+ function draw ({ focus } = {}) {
34
+ if (!alive) return
35
+ const had = root.contains(document.activeElement) ? document.activeElement.getAttribute('name') : null
36
+ // Keep what was typed, unless the form was just cleared on purpose.
37
+ if (fresh) fresh = false
38
+ else capture()
39
+ root.innerHTML = secretsCardHtml(view, { ...ui, now: Date.now() })
40
+ const f = root.querySelector('[data-add]')
41
+ if (f) f.elements.value.value = typed
42
+ const want = focus || had
43
+ if (want && f && f.elements[want]) f.elements[want].focus()
44
+ }
45
+
46
+ async function load () {
47
+ try {
48
+ view = await api('GET', urlOf(ui.cur.type, ui.cur.id))
49
+ views.set(`${ui.cur.type}:${ui.cur.id}`, view)
50
+ if (!ui.tabs.length) ui.tabs = levelTabs(view)
51
+ ui.error = ''
52
+ } catch (err) {
53
+ ui.error = err.status === 503 ? 'Secrets are not switched on for your account\'s server yet.' : err.message
54
+ view = null
55
+ }
56
+ draw()
57
+ }
58
+
59
+ const say = (text, kind = '') => { ui.msg = text ? { text, kind } : null }
60
+ const resetForm = () => { ui.form = { format: 'bearer' }; ui.mode = null; typed = ''; fresh = true }
61
+ const scopeUrl = (rest) => urlOf(ui.cur.type, ui.cur.id, rest)
62
+
63
+ async function act (fn) {
64
+ if (ui.busy) return
65
+ ui.busy = true
66
+ try { await fn() } catch (err) { say(err.message, 'bad') } finally { ui.busy = false; draw() }
67
+ }
68
+
69
+ const setSettings = (patch) => act(async () => { view = await api('POST', scopeUrl('/settings'), patch); views.set(`${ui.cur.type}:${ui.cur.id}`, view); say('') })
70
+
71
+ function formatOf (f) {
72
+ if (f.format === 'custom') {
73
+ const c = parseCustomFormat(f.custom)
74
+ if (!c) throw new Error('Write your header like X-Token: Token {value}')
75
+ return c
76
+ }
77
+ return FORMAT_IDS.includes(f.format) ? f.format : 'bearer'
78
+ }
79
+
80
+ async function save () {
81
+ capture()
82
+ const f = ui.form
83
+ let name
84
+ try { name = cleanSecretName(f.name) } catch (err) { say(err.message, 'bad'); return draw({ focus: 'name' }) }
85
+ if (!typed) { say(`Type ${name}'s value.`, 'bad'); return draw({ focus: 'value' }) }
86
+ let format
87
+ try { format = formatOf(f) } catch (err) { say(err.message, 'bad'); return draw({ focus: 'custom' }) }
88
+ const had = view.secrets.find((s) => s.name === name)
89
+ await act(async () => {
90
+ const r = await api('POST', scopeUrl('/save'), { entries: [{ name, upstream: f.upstream, format, who: had ? had.who : 'agents' }], values: { [name]: typed } })
91
+ typed = ''
92
+ resetForm()
93
+ const d = r.saved && r.saved[0]
94
+ say(`${had ? 'Replaced' : 'Saved'} ${name}${d ? ` (${d.digest})` : ''}. Nobody can see its value again, you included.`, 'ok')
95
+ ui.flash = name
96
+ view = await api('GET', urlOf(ui.cur.type, ui.cur.id))
97
+ views.set(`${ui.cur.type}:${ui.cur.id}`, view)
98
+ })
99
+ }
100
+
101
+ // --------------------------------------------------------------- Import .env --
102
+ async function openImport () {
103
+ ui.import = { loading: true, files: [], rows: [], fileIdx: 0 }
104
+ draw()
105
+ try {
106
+ const r = await api('POST', '/api/secrets/env-scan', importFrom)
107
+ ui.import = { files: r.files, fileIdx: 0, rows: [], empty: r.empty || '' }
108
+ pickFile(0)
109
+ } catch (err) { ui.import = null; say(err.message, 'bad') }
110
+ draw()
111
+ }
112
+ function pickFile (i) {
113
+ const f = ui.import.files[i]
114
+ ui.import.fileIdx = i
115
+ ui.import.rows = f ? f.entries.map((e) => ({ name: e.name, upstream: e.upstream || '', format: e.format || 'bearer', who: 'agents', skip: e.skip || '', pick: !e.skip && !!e.upstream })) : []
116
+ }
117
+ function captureImport () {
118
+ if (!ui.import) return
119
+ root.querySelectorAll('[data-import-host]').forEach((el) => { ui.import.rows[+el.dataset.importHost].upstream = el.value })
120
+ root.querySelectorAll('[data-import-format]').forEach((el) => { ui.import.rows[+el.dataset.importFormat].format = el.value })
121
+ }
122
+ async function importSave () {
123
+ captureImport()
124
+ const im = ui.import
125
+ const rows = im.rows.filter((r) => r.pick && !r.skip)
126
+ if (rows.some((r) => r.format === 'custom')) { say('An imported secret uses one of the listed formats; add a header of your own one at a time.', 'bad'); return draw() }
127
+ const file = im.files[im.fileIdx]
128
+ await act(async () => {
129
+ const r = await api('POST', '/api/secrets/env-import', { ...importFrom, scope: ui.cur.type, id: ui.cur.id, file: file.path, entries: rows.map((x) => ({ name: x.name, upstream: x.upstream, format: x.format, who: x.who })) })
130
+ ui.import = { ...im, done: { rel: file.rel, path: file.path, saved: r.saved, deleted: false } }
131
+ say('')
132
+ view = await api('GET', urlOf(ui.cur.type, ui.cur.id))
133
+ views.set(`${ui.cur.type}:${ui.cur.id}`, view)
134
+ })
135
+ }
136
+
137
+ // --------------------------------------------------------------- events --
138
+ async function onClick (e) {
139
+ const t = (sel) => e.target.closest(sel)
140
+ let b
141
+ if ((b = t('[data-level]'))) {
142
+ if (b.classList.contains('on')) return
143
+ capture(); resetForm(); ui.choose = false; ui.import = null; ui.confirmDelete = null; say('')
144
+ ui.cur = { type: b.dataset.level, id: b.dataset.levelId }
145
+ view = views.get(`${ui.cur.type}:${ui.cur.id}`) || null
146
+ draw()
147
+ return load()
148
+ }
149
+ if (!view) return
150
+ if ((b = t('[data-inherit]')) && !b.disabled) return setSettings({ inheritOff: !view.settings.inheritOff })
151
+ if (t('[data-inherit-on]')) return setSettings({ inheritOff: false })
152
+ if ((b = t('[data-agents-on]')) && !b.disabled) { ui.choose = false; return setSettings({ agentsOn: !view.settings.agentsOn }) }
153
+ if (t('[data-choose]')) { ui.choose = !ui.choose; ui.chooseSel = view.settings.agents === 'all' ? 'all' : [...view.settings.agents]; return draw() }
154
+ if (t('[data-choose-cancel]')) { ui.choose = false; return draw() }
155
+ if (t('[data-choose-save]')) { ui.choose = false; return setSettings({ agents: ui.chooseSel }) }
156
+ if ((b = t('[data-who]')) && !b.disabled) {
157
+ const r = view.secrets.find((s) => s.name === b.dataset.who)
158
+ return act(async () => { view = await api('POST', scopeUrl('/who'), { name: r.name, who: r.who === 'people' ? 'agents' : 'people' }); say('') })
159
+ }
160
+ if ((b = t('[data-replace]')) || (b = t('[data-override]'))) {
161
+ const replace = !!b.dataset.replace
162
+ const name = replace ? b.dataset.replace : b.dataset.override
163
+ const r = (replace ? view.secrets : view.inherited).find((s) => s.name === name)
164
+ ui.mode = { kind: replace ? 'replace' : 'override', name }
165
+ const fid = formatId(r.format)
166
+ ui.form = { name, upstream: r.upstream, format: fid || 'custom', custom: fid ? '' : `${r.format.name}: ${r.format.template}` }
167
+ typed = ''
168
+ fresh = true
169
+ say(replace ? `A new value for ${name}. Where it goes and how can change with it.` : `${name} for this ${ui.cur.type === 'org' ? 'team' : ui.cur.type} only: it overrides the ${r.level === 'org' ? 'team' : r.level}'s.`)
170
+ return draw({ focus: 'value' })
171
+ }
172
+ if (t('[data-add-cancel]')) { resetForm(); say(''); return draw() }
173
+ if ((b = t('[data-delete]'))) { ui.confirmDelete = b.dataset.delete; return draw() }
174
+ if (t('[data-delete-no]')) { ui.confirmDelete = null; return draw() }
175
+ if ((b = t('[data-delete-yes]'))) {
176
+ const name = b.dataset.deleteYes
177
+ ui.confirmDelete = null
178
+ return act(async () => { view = await api('POST', scopeUrl('/delete'), { name }); say(`Deleted ${name}.`, 'ok') })
179
+ }
180
+ if (t('[data-import]')) return openImport()
181
+ if (t('[data-import-close]')) { ui.import = null; return draw() }
182
+ if ((b = t('[data-import-who]'))) { captureImport(); const r = ui.import.rows[+b.dataset.importWho]; r.who = r.who === 'people' ? 'agents' : 'people'; return draw() }
183
+ if (t('[data-import-save]')) return importSave()
184
+ if (t('[data-import-delete]')) {
185
+ const d = ui.import.done
186
+ return act(async () => { await api('POST', '/api/secrets/env-delete', { ...importFrom, file: d.path }); d.deleted = true; toast(`Deleted ${d.rel}`) })
187
+ }
188
+ }
189
+
190
+ function onChange (e) {
191
+ const el = e.target
192
+ if (el.matches('[data-choose-all]')) { ui.chooseSel = 'all'; return draw() }
193
+ if (el.matches('[data-choose-some]')) { ui.chooseSel = view.agentChoices.map((a) => a.id); return draw() }
194
+ if (el.matches('[data-choose-agent]')) {
195
+ const set = new Set(ui.chooseSel === 'all' ? [] : ui.chooseSel)
196
+ if (el.checked) set.add(el.dataset.chooseAgent); else set.delete(el.dataset.chooseAgent)
197
+ ui.chooseSel = [...set]
198
+ return
199
+ }
200
+ if (el.matches('[data-add] [name=format]')) { capture(); return draw({ focus: el.value === 'custom' ? 'custom' : 'format' }) }
201
+ if (el.matches('[data-import-file]')) { pickFile(+el.value); return draw() }
202
+ if (el.matches('[data-import-pick]')) { captureImport(); ui.import.rows[+el.dataset.importPick].pick = el.checked; return draw() }
203
+ }
204
+
205
+ function onInput (e) {
206
+ // Names are capitals: typed as such.
207
+ if (e.target.matches('[data-add] [name=name]')) {
208
+ const s = e.target.selectionStart
209
+ e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '_')
210
+ e.target.setSelectionRange(s, s)
211
+ }
212
+ }
213
+
214
+ function onSubmit (e) {
215
+ if (!e.target.matches('[data-add]')) return
216
+ e.preventDefault()
217
+ save()
218
+ }
219
+
220
+ root.addEventListener('click', onClick)
221
+ root.addEventListener('change', onChange)
222
+ root.addEventListener('input', onInput)
223
+ root.addEventListener('submit', onSubmit)
224
+ draw()
225
+ load()
226
+ return {
227
+ refresh: load,
228
+ destroy () { alive = false; typed = ''; root.removeEventListener('click', onClick); root.removeEventListener('change', onChange); root.removeEventListener('input', onInput); root.removeEventListener('submit', onSubmit) }
229
+ }
230
+ }
231
+
232
+ // ------------------------------------------------- people-only asks, app-wide --
233
+ let asking = false
234
+ let asks = []
235
+
236
+ function drawAsks () {
237
+ let bar = document.getElementById('ask-bar')
238
+ const show = asks.length > 0
239
+ document.body.classList.toggle('has-asks', show)
240
+ if (!show) { bar?.remove(); return }
241
+ if (!bar) {
242
+ bar = document.createElement('div')
243
+ bar.id = 'ask-bar'
244
+ bar.addEventListener('click', async (e) => {
245
+ const b = e.target.closest('[data-ask-allow],[data-ask-deny]')
246
+ if (!b) return
247
+ const allow = !!b.dataset.askAllow
248
+ const runId = allow ? b.dataset.askAllow : b.dataset.askDeny
249
+ b.disabled = true
250
+ try {
251
+ await api('POST', `/api/secret-asks/${encodeURIComponent(runId)}`, { allow })
252
+ asks = asks.filter((a) => a.runId !== runId)
253
+ toast(allow ? 'Allowed for that run' : 'Denied')
254
+ } catch (err) { toast(err.message) }
255
+ drawAsks()
256
+ })
257
+ document.body.prepend(bar)
258
+ }
259
+ bar.innerHTML = asksBarHtml(asks)
260
+ }
261
+
262
+ /**
263
+ * Watches for runs of yours waiting for you to allow a people-only secret (a held request that
264
+ * the API answers when one comes, or after 25 seconds), and shows them in the bar. Once per page.
265
+ */
266
+ export function watchSecretAsks () {
267
+ if (asking) return
268
+ asking = true
269
+ const loop = async () => {
270
+ let wait = 2000
271
+ try {
272
+ const r = await api('GET', '/api/secret-asks?wait=1')
273
+ asks = r.asks || []
274
+ drawAsks()
275
+ wait = asks.length ? 3000 : 250
276
+ } catch (err) {
277
+ // Secrets off on the server, or signed out: look again much later.
278
+ wait = err.status === 503 || err.status === 404 ? 10 * 60 * 1000 : 15000
279
+ }
280
+ setTimeout(loop, wait)
281
+ }
282
+ loop()
283
+ }