heyquilt 0.3.90 → 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 (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +131 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +141 -5
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +143 -17
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +120 -10
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +136 -27
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. package/src/ui/merges.js +0 -158
@@ -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
+ }
@@ -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
+ }