heyquilt 0.0.1 → 0.3.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +630 -2
  3. package/RELEASES.md +733 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +816 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +563 -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 +180 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +170 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +98 -0
  19. package/src/ai-vm.js +380 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +130 -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 +76 -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 +1083 -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 +251 -0
  40. package/src/api/relay-machines.js +60 -0
  41. package/src/api/routes/access-types.js +54 -0
  42. package/src/api/routes/agent-invites.js +115 -0
  43. package/src/api/routes/agents.js +157 -0
  44. package/src/api/routes/ai-vm.js +369 -0
  45. package/src/api/routes/github.js +380 -0
  46. package/src/api/routes/grants.js +70 -0
  47. package/src/api/routes/invites.js +188 -0
  48. package/src/api/routes/issues.js +47 -0
  49. package/src/api/routes/join.js +167 -0
  50. package/src/api/routes/members.js +110 -0
  51. package/src/api/routes/orgs.js +177 -0
  52. package/src/api/routes/relay-cluster.js +90 -0
  53. package/src/api/routes/relay.js +76 -0
  54. package/src/api/routes/session-invites.js +119 -0
  55. package/src/api/routes/sessions.js +92 -0
  56. package/src/api/routes/teams.js +132 -0
  57. package/src/api/routes/workspace-agents.js +332 -0
  58. package/src/api/routes/workspace-files.js +277 -0
  59. package/src/api/routes/workspace-invites.js +196 -0
  60. package/src/api/routes/workspaces.js +225 -0
  61. package/src/api/server.js +623 -0
  62. package/src/api/slugs.js +20 -0
  63. package/src/api/supabase-store.js +765 -0
  64. package/src/api/team-access.js +30 -0
  65. package/src/api/tokens.js +31 -0
  66. package/src/api/workspace-access.js +62 -0
  67. package/src/api/workspace-reach.js +60 -0
  68. package/src/autowork-tools.js +166 -0
  69. package/src/autowork.js +387 -0
  70. package/src/blobstore.js +99 -0
  71. package/src/boxes.js +37 -0
  72. package/src/branchdocs.js +235 -0
  73. package/src/branches.js +194 -0
  74. package/src/catchup.js +111 -0
  75. package/src/chat-archive.js +85 -0
  76. package/src/chat-links.js +923 -0
  77. package/src/clash.js +140 -0
  78. package/src/cloud-ai.js +50 -0
  79. package/src/commit-request.js +59 -0
  80. package/src/commit.js +213 -0
  81. package/src/connection.js +569 -0
  82. package/src/control.js +245 -0
  83. package/src/conversation.js +125 -0
  84. package/src/doctor.js +156 -0
  85. package/src/duties.js +303 -0
  86. package/src/editors.js +228 -0
  87. package/src/fsutil.js +87 -0
  88. package/src/git-hook.js +48 -0
  89. package/src/git.js +147 -0
  90. package/src/github-commit.js +320 -0
  91. package/src/gitignore.js +49 -0
  92. package/src/gitstate.js +900 -0
  93. package/src/history.js +307 -0
  94. package/src/hooks.js +371 -0
  95. package/src/hosted-agents.js +24 -0
  96. package/src/hosted-client.js +112 -0
  97. package/src/identity.js +99 -0
  98. package/src/inbox.js +237 -0
  99. package/src/integrations.js +221 -0
  100. package/src/key-cli.js +115 -0
  101. package/src/largefiles.js +91 -0
  102. package/src/legacy.js +25 -0
  103. package/src/mcp-proxy.js +40 -0
  104. package/src/mcp-room.js +32 -0
  105. package/src/mcp.js +1261 -0
  106. package/src/merge3.js +158 -0
  107. package/src/merges.js +148 -0
  108. package/src/pass-source.js +176 -0
  109. package/src/passes.js +64 -0
  110. package/src/pathrules.js +127 -0
  111. package/src/persona.js +101 -0
  112. package/src/presence.js +360 -0
  113. package/src/private-file.js +41 -0
  114. package/src/procs.js +70 -0
  115. package/src/project-rules.js +178 -0
  116. package/src/protocol.js +92 -0
  117. package/src/quiet-warnings.js +9 -0
  118. package/src/relay-ai-vm.js +82 -0
  119. package/src/relay-ask.js +141 -0
  120. package/src/relay-ci.js +111 -0
  121. package/src/relay-clash.js +597 -0
  122. package/src/relay-cluster.js +454 -0
  123. package/src/relay-commit.js +887 -0
  124. package/src/relay-filekey.js +46 -0
  125. package/src/relay-git.js +274 -0
  126. package/src/relay-githttp.js +137 -0
  127. package/src/relay-github.js +109 -0
  128. package/src/relay-lease.js +137 -0
  129. package/src/relay-lfs.js +37 -0
  130. package/src/relay-mcp.js +1679 -0
  131. package/src/relay-metrics.js +75 -0
  132. package/src/relay-outbound.js +1144 -0
  133. package/src/relay-sync.js +719 -0
  134. package/src/relay-turn-sync.js +307 -0
  135. package/src/relay-upstream.js +934 -0
  136. package/src/relay-webhooks.js +111 -0
  137. package/src/releases.js +131 -0
  138. package/src/report.js +129 -0
  139. package/src/runner.js +306 -0
  140. package/src/same-edit.js +18 -0
  141. package/src/server.js +4187 -0
  142. package/src/session-access.js +170 -0
  143. package/src/session-name.js +15 -0
  144. package/src/session.js +7361 -0
  145. package/src/settings.js +46 -0
  146. package/src/setup.js +184 -0
  147. package/src/shell-path.js +46 -0
  148. package/src/status.js +199 -0
  149. package/src/summarize.js +109 -0
  150. package/src/sync-words.js +42 -0
  151. package/src/task-comments.js +92 -0
  152. package/src/tasks.js +670 -0
  153. package/src/textdiff.js +20 -0
  154. package/src/turn-sync-plan.js +142 -0
  155. package/src/turn-sync.js +191 -0
  156. package/src/ui/access-form.js +37 -0
  157. package/src/ui/agent-env-key.js +76 -0
  158. package/src/ui/agent-guide.js +127 -0
  159. package/src/ui/agent-kinds.js +175 -0
  160. package/src/ui/agent-place.js +265 -0
  161. package/src/ui/app.css +2309 -0
  162. package/src/ui/app.js +542 -0
  163. package/src/ui/board.js +465 -0
  164. package/src/ui/boxes.js +76 -0
  165. package/src/ui/branches.js +111 -0
  166. package/src/ui/catchup.js +77 -0
  167. package/src/ui/changes.js +159 -0
  168. package/src/ui/chat.js +266 -0
  169. package/src/ui/cloud-ai-model.js +107 -0
  170. package/src/ui/cloud-ai.js +221 -0
  171. package/src/ui/common.js +431 -0
  172. package/src/ui/csv.js +21 -0
  173. package/src/ui/diffview.js +74 -0
  174. package/src/ui/feed-convs.js +39 -0
  175. package/src/ui/feed.js +174 -0
  176. package/src/ui/files.js +579 -0
  177. package/src/ui/fileview.js +123 -0
  178. package/src/ui/git-rule.js +60 -0
  179. package/src/ui/home.js +1323 -0
  180. package/src/ui/index.html +16 -0
  181. package/src/ui/invite.js +97 -0
  182. package/src/ui/invites.js +79 -0
  183. package/src/ui/loom-model.js +516 -0
  184. package/src/ui/loom.css +349 -0
  185. package/src/ui/loom.js +753 -0
  186. package/src/ui/mark.js +104 -0
  187. package/src/ui/merges.js +158 -0
  188. package/src/ui/rail-model.js +78 -0
  189. package/src/ui/rail.js +173 -0
  190. package/src/ui/releases.js +182 -0
  191. package/src/ui/schedule.js +156 -0
  192. package/src/ui/session.js +3394 -0
  193. package/src/ui/signin.js +75 -0
  194. package/src/ui/sounds.js +110 -0
  195. package/src/ui/tool-logo.js +86 -0
  196. package/src/ui/tree.js +276 -0
  197. package/src/ui/workspaces.js +542 -0
  198. package/src/ui-server.js +1272 -0
  199. package/src/update-check.js +67 -0
  200. package/src/upstream.js +155 -0
  201. package/src/webhooks.js +235 -0
  202. package/src/workspace-cli.js +62 -0
  203. package/src/workspace-tools.js +340 -0
  204. package/index.js +0 -2
@@ -0,0 +1,182 @@
1
+ // What's new and the update check: a bar across the top when a newer Quilt is out, and the
2
+ // release notes in a quilted pop-up (shown by itself the first time a new version runs, and
3
+ // again when a newer release appears). In the desktop app, "Update Quilt" installs it.
4
+ import { I, state, $, esc, api, remember, recall, toast } from './common.js'
5
+ import { quiltMark } from './mark.js'
6
+
7
+ const RECHECK_MS = 10 * 60 * 1000
8
+ let shownUnseen = false
9
+ let hiddenBar = false
10
+
11
+ /** Asks the app about versions, shows the update bar, and opens the notes after an update or a new release. */
12
+ export async function checkRelease () {
13
+ try { state.release = await api('GET', '/api/version') } catch { return }
14
+ const r = state.release
15
+ // Each newer release is announced once per computer: when it is cut while the app is open, or at the next launch.
16
+ // Not one you said not to show again.
17
+ if (r.outOfDate && !r.barHidden && recall('announced-release') !== r.latest.version) {
18
+ remember('announced-release', r.latest.version)
19
+ hiddenBar = false
20
+ openReleaseNotes()
21
+ } else if (r.unseen && !shownUnseen) {
22
+ openReleaseNotes()
23
+ }
24
+ if (r.unseen && !shownUnseen) {
25
+ shownUnseen = true
26
+ api('POST', '/api/version/seen').catch(() => {})
27
+ r.unseen = false
28
+ }
29
+ renderUpdateBar()
30
+ if (!checkRelease.timer) checkRelease.timer = setInterval(() => checkRelease().catch(() => {}), RECHECK_MS)
31
+ }
32
+
33
+ // ------------------------------------------------------------- update --
34
+ const desktop = () => window.quiltDesktop?.installUpdate ? window.quiltDesktop : null
35
+ let update = null // { phase: 'downloading' | 'installing' | 'restarting' | 'failed', received, total, error }
36
+
37
+ /** Downloads and installs the newest Quilt from inside the desktop app. */
38
+ async function startUpdate () {
39
+ const d = desktop()
40
+ if (!d || (update && update.phase !== 'failed')) return
41
+ if (!startUpdate.listening) { startUpdate.listening = true; d.onUpdateProgress?.((p) => { update = { ...update, ...p }; refreshUpdateControls() }) }
42
+ update = { phase: 'downloading', received: 0, total: 0 }
43
+ refreshUpdateControls()
44
+ try {
45
+ await d.installUpdate()
46
+ update = { phase: 'restarting' }
47
+ } catch (err) {
48
+ update = { phase: 'failed', error: String(err?.message || err).replace(/^Error invoking remote method '[^']*': (Error: )?/, '') }
49
+ }
50
+ refreshUpdateControls()
51
+ }
52
+
53
+ /** The button that gets the newer Quilt: installs it in the desktop app, downloads it elsewhere. */
54
+ export function updateControl () {
55
+ const r = state.release
56
+ const download = `<a class="btn sm primary" href="${esc(r.downloadUrl)}" target="_blank" rel="noopener">${I.down}<span>Download</span></a>`
57
+ if (!desktop()) return `<span class="update-ctl">${download}</span>`
58
+ const phase = update?.phase
59
+ if (!phase) return `<span class="update-ctl"><button class="btn sm primary" type="button" data-update>${I.down}<span>Update Quilt</span></button></span>`
60
+ if (phase === 'failed') {
61
+ return `<span class="update-ctl update-failed"><span class="update-err" title="${esc(update.error)}">${esc(update.error)}</span><button class="btn sm primary" type="button" data-update>${I.refresh}<span>Try again</span></button>${download}</span>`
62
+ }
63
+ const pct = phase === 'downloading' && update.total ? ` ${Math.min(99, Math.floor(update.received / update.total * 100))}%` : ''
64
+ const label = phase === 'downloading' ? `Downloading…${pct}` : phase === 'installing' ? 'Installing…' : 'Restarting…'
65
+ return `<span class="update-ctl"><button class="btn sm primary update-busy" type="button" disabled>${quiltMark({ word: false, loop: true, cls: 'qm-inline' })}<span>${esc(label)}</span></button></span>`
66
+ }
67
+
68
+ /** Redraws every update button (the bar's and the pop-up's) in place. */
69
+ function refreshUpdateControls () {
70
+ if (!state.release) return
71
+ for (const el of document.querySelectorAll('.update-ctl')) el.outerHTML = updateControl()
72
+ }
73
+
74
+ /** The bar at the top of the window: this version is out of date. */
75
+ export function renderUpdateBar () {
76
+ const r = state.release
77
+ let bar = $('#update-bar')
78
+ const show = !!r?.outOfDate && !hiddenBar && !r.barHidden
79
+ document.body.classList.toggle('has-update', show)
80
+ if (!show) { bar?.remove(); return }
81
+ if (!bar) {
82
+ bar = document.createElement('div')
83
+ bar.id = 'update-bar'
84
+ bar.setAttribute('role', 'status')
85
+ document.body.prepend(bar)
86
+ }
87
+ bar.innerHTML = `
88
+ <span class="ub-dot"></span>
89
+ <span class="ub-text"><b>Quilt ${esc(r.latest.version)} is out.</b> You have ${esc(r.version)}.</span>
90
+ <button class="btn sm ghost" type="button" data-release-notes>What's new</button>
91
+ ${updateControl()}
92
+ <label class="ub-never" title="Hide this bar until a newer Quilt than ${esc(r.latest.version)} is out"><input type="checkbox" data-update-never><span class="ub-long">Don't show this again</span><span class="ub-short">Don't show again</span></label>
93
+ <button class="btn sm ghost icon ub-x" type="button" data-hide-update aria-label="Hide until next time" title="Hide until Quilt starts again">${I.x}</button>`
94
+ }
95
+
96
+ /** Bold and code only; everything else is text. */
97
+ export function inline (md) {
98
+ return esc(md)
99
+ .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
100
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
101
+ }
102
+
103
+ const PATCHES = ['a', 'b', 'c', 'd']
104
+ const when = (d) => {
105
+ if (!d) return ''
106
+ const t = new Date(`${d}T12:00:00Z`)
107
+ return isNaN(t) ? d : t.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' })
108
+ }
109
+
110
+ function releaseHtml (rel, i, { current = false, latest = false } = {}) {
111
+ return `
112
+ <section class="rn-rel${latest ? ' rn-latest' : ''}" aria-labelledby="rn-v-${esc(rel.version)}">
113
+ <header class="rn-rel-head">
114
+ <span class="rn-patch rn-patch-${PATCHES[i % PATCHES.length]}" id="rn-v-${esc(rel.version)}">${esc(rel.version)}</span>
115
+ <span class="rn-when">${esc(when(rel.date))}</span>
116
+ ${current ? '<span class="rn-tag">You have this</span>' : ''}
117
+ ${latest ? updateControl() : ''}
118
+ </header>
119
+ ${rel.summary ? `<p class="rn-sum">${inline(rel.summary)}</p>` : ''}
120
+ ${rel.items.length ? `<ul class="rn-list">${rel.items.map((it) => `<li>${inline(it)}</li>`).join('')}</ul>` : ''}
121
+ </section>`
122
+ }
123
+
124
+ /** The release notes pop-up: the logo, then every version newest first (and a newer one from GitHub, if any). */
125
+ export function openReleaseNotes () {
126
+ const r = state.release
127
+ if (!r) return
128
+ $('#rn-back')?.remove()
129
+ const back = document.createElement('div')
130
+ back.id = 'rn-back'
131
+ back.className = 'modal-back rn-back'
132
+ const newer = r.outOfDate ? [{ ...r.latest, items: r.latest.items.length ? r.latest.items : ['**A newer Quilt is ready.** Download it to see what changed.'] }] : []
133
+ const rels = r.releases || []
134
+ back.innerHTML = `
135
+ <div class="rn quilt-patch" role="dialog" aria-modal="true" aria-labelledby="rn-title">
136
+ <span class="quilt-stitch" aria-hidden="true"></span>
137
+ <div class="rn-card">
138
+ <header class="rn-head">
139
+ <div class="rn-logo">${quiltMark({ sew: true })}</div>
140
+ <h2 id="rn-title">What's new</h2>
141
+ <p class="rn-lead">${r.outOfDate ? `Quilt <b>${esc(r.latest.version)}</b> is out; you have ${esc(r.version)}.${desktop() ? ' Update takes a minute and restarts Quilt.' : ''}` : `You have the newest Quilt, <b>${esc(r.version)}</b>.`}</p>
142
+ <button class="btn icon ghost rn-close" type="button" data-rn-close aria-label="Close">${I.x}</button>
143
+ </header>
144
+ <div class="rn-body">
145
+ ${newer.map((rel) => releaseHtml(rel, 0, { latest: true })).join('')}
146
+ ${rels.map((rel, i) => releaseHtml(rel, i + newer.length, { current: rel.version === r.version })).join('')}
147
+ <footer class="rn-foot">All releases live on <a href="https://github.com/DanielCarmichaelGit/heyquilt/releases" target="_blank" rel="noopener">GitHub</a>.</footer>
148
+ </div>
149
+ </div>
150
+ </div>`
151
+ document.body.appendChild(back)
152
+ const close = () => back.remove()
153
+ back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
154
+ back.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
155
+ back.querySelector('[data-rn-close]').onclick = close
156
+ back.querySelector('[data-rn-close]').focus()
157
+ }
158
+
159
+ document.addEventListener('click', (e) => {
160
+ if (e.target.closest('[data-release-notes]')) {
161
+ if (state.release) openReleaseNotes()
162
+ else checkRelease().then(openReleaseNotes)
163
+ }
164
+ if (e.target.closest('[data-hide-update]')) { hiddenBar = true; renderUpdateBar() }
165
+ if (e.target.closest('[data-update]')) startUpdate()
166
+ })
167
+
168
+ // "Don't show this again": hides the bar now, and for good for this release (a newer one shows it again).
169
+ document.addEventListener('change', async (e) => {
170
+ const never = e.target.closest?.('[data-update-never]')
171
+ if (!never || !never.checked || !state.release?.latest) return
172
+ const version = state.release.latest.version
173
+ try {
174
+ await api('POST', '/api/version/hide-bar', { version })
175
+ state.release.barHidden = true
176
+ renderUpdateBar()
177
+ toast(`Hidden until a newer Quilt than ${version} is out. What's New in Quilt still has it.`)
178
+ } catch (err) {
179
+ never.checked = false
180
+ toast(err.message)
181
+ }
182
+ })
@@ -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
+