habicron 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 thecodeorigin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,278 @@
1
+ # habicron
2
+
3
+ > Habits, not cronjobs.
4
+
5
+ Schedule callbacks on **randomized recurring intervals**. `habicron` is accurate
6
+ by default (evenly spaced, anchored to the start time, **no drift**) and
7
+ optionally **jittered** — each fire nudged earlier or later within bounds, so
8
+ your jobs run on a human rhythm instead of robotically on the dot.
9
+
10
+ One tiny engine, five entry points:
11
+
12
+ | Import | For | Returns |
13
+ | --- | --- | --- |
14
+ | `habicron` / `habicron/node` | Node, workers, scripts | a plain controller |
15
+ | `habicron/browser` | Vanilla browser (no framework) | controller + callbacks |
16
+ | `habicron/vue` | Vue 3 | reactive `ref`s |
17
+ | `habicron/react` | React 17+ | reactive state |
18
+ | `habit` (CLI) | the terminal | runs a shell command |
19
+
20
+ - **No drift** — fires are anchored to a fixed grid; jitter perturbs *around* the
21
+ grid point and never accumulates.
22
+ - **Jitter, bounded** — capped at `0.49 × interval` so adjacent fires can't reorder.
23
+ - **Long delays** — months/years are supported (chunked past `setTimeout`'s 24.8-day ceiling).
24
+ - **Resilient** — a throwing or rejecting callback never kills the schedule.
25
+ - **SSR-safe** — no timers during server render.
26
+ - **Typed** — ships hand-checked `.d.ts`; mutually-exclusive `every` vs `times`/`per`.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ npm i habicron # or: pnpm add habicron / bun add habicron
32
+ ```
33
+
34
+ `vue` and `react` are optional peer dependencies — install only the one you use.
35
+
36
+ ## Schedule shapes
37
+
38
+ A **habit** is an interval (`every`) or a rate (`times` + `per`), with optional
39
+ `jitter`. Every shape:
40
+
41
+ | Shape | Example | Fires |
42
+ | --- | --- | --- |
43
+ | `{ every: Duration }` | `{ every: '2h' }` | every 2 hours, exactly |
44
+ | `{ every: Duration }` (compound) | `{ every: '1h30m' }` | every 90 minutes |
45
+ | `{ every: '<cadence> ~ <jitter>' }` | `{ every: '2h ~ 5m' }` | every 2h, ± up to 5m (packed; `+/-` also works) |
46
+ | `{ every, jitter: Duration }` | `{ every: '20s', jitter: '5s' }` | every 20s, ± up to 5s |
47
+ | `{ every, jitter: [min, max] }` | `{ every: '20s', jitter: ['3s', '5s'] }` | every 20s, ± 3–5s |
48
+ | `{ every, jitter: { min, max } }` | `{ every: '1h', jitter: { min: '5m', max: '15m' } }` | every 1h, ± 5–15m |
49
+ | `{ times, per }` | `{ times: 2, per: 'day' }` | twice a day, evenly spaced |
50
+ | `{ times, per, jitter }` | `{ times: 2, per: 'day', jitter: '2h' }` | twice a day, ± up to 2h |
51
+ | `{ habits: Schedule[] }` | `{ habits: [a, b, c] }` | the **union** of several habits |
52
+
53
+ Plus the control flags (any shape): `immediate?` (fire once on start),
54
+ `autoStart?` (default `true`), `random?` (seeded RNG), `id?` / `name?` (for the
55
+ registry), and — adapters only — `controls?`.
56
+
57
+ **Durations** are a number (ms) or a string of `<num><unit>` tokens:
58
+
59
+ | Token | Unit | | Token | Unit |
60
+ | --- | --- | --- | --- | --- |
61
+ | `ms` | milliseconds | | `w` | weeks |
62
+ | `s` | seconds | | `mo` | months (avg 30.44 d) |
63
+ | `m` | minutes | | `y` | years (avg 365.25 d) |
64
+ | `h` | hours | | | |
65
+ | `d` | days | | | |
66
+
67
+ **`per`** is one of `minute` `hour` `day` `week` `month` `year`. Jitter sign is
68
+ always random (fires land earlier **or** later), and its magnitude is capped at
69
+ `0.49 × interval` so adjacent fires can't reorder.
70
+
71
+ ## Node
72
+
73
+ ```ts
74
+ import { createHabit } from 'habicron'
75
+
76
+ const job = createHabit(() => syncFeed(), { every: '15m ~ 2m' })
77
+
78
+ job.counter // times fired
79
+ job.nextRun // Date of the next fire, or null
80
+ job.pause()
81
+ job.resume()
82
+ job.stop()
83
+
84
+ process.on('SIGINT', () => { job.stop(); process.exit(0) })
85
+ ```
86
+
87
+ ## Browser (no framework)
88
+
89
+ Vanilla JS has no refs or state, so reactivity comes through callbacks —
90
+ `onActive`, `onFire`, `onChange`:
91
+
92
+ ```ts
93
+ import { useHabit } from 'habicron/browser'
94
+
95
+ const job = useHabit(() => refreshWidget(), {
96
+ every: '20s ~ 4s',
97
+ onFire: count => (badge.textContent = String(count)),
98
+ onActive: active => dot.classList.toggle('live', active),
99
+ })
100
+
101
+ job.pause() // or resume / update / destroy
102
+ ```
103
+
104
+ `onActive` is the framework-free stand-in for a reactive `isActive`. SSR-safe:
105
+ timers don't start unless a `window` is present.
106
+
107
+ ## Vue
108
+
109
+ ```vue
110
+ <script setup lang="ts">
111
+ import { useHabit } from 'habicron/vue'
112
+
113
+ const { counter, nextRun, pause, resume } = useHabit(post, {
114
+ controls: true,
115
+ every: '20s ~ 4s',
116
+ })
117
+ </script>
118
+
119
+ <template>
120
+ <p>fired {{ counter }}× · next at {{ nextRun?.toLocaleTimeString() }}</p>
121
+ <button @click="pause">Pause</button>
122
+ </template>
123
+ ```
124
+
125
+ `counter`, `nextRun` and `isActive` are readonly refs. Control members
126
+ (`pause`, `resume`, `reset`, `isActive`) appear only when `controls: true`.
127
+
128
+ ## React
129
+
130
+ ```tsx
131
+ import { useHabit } from 'habicron/react'
132
+
133
+ function Reminder() {
134
+ const { counter, nextRun, pause } = useHabit(
135
+ () => notify('Drink water'),
136
+ { controls: true, every: '1h ~ 8m' },
137
+ )
138
+ return (
139
+ <p>
140
+ fired {counter}× · next at {nextRun?.toLocaleTimeString()}
141
+ <button onClick={pause}>Pause</button>
142
+ </p>
143
+ )
144
+ }
145
+ ```
146
+
147
+ React returns plain values (not refs). The controller is created inside an
148
+ effect, so it is SSR-safe; the callback is always read fresh.
149
+
150
+ ## Multiple habits
151
+
152
+ ```ts
153
+ useHabit(runAgent, {
154
+ controls: true,
155
+ habits: [
156
+ { every: '2h ~ 20m' }, // check the cat
157
+ { times: 2, per: 'day', jitter: '90m' }, // twice a day
158
+ { every: '3d', jitter: ['3h', '5h'] }, // every few days
159
+ ],
160
+ })
161
+ ```
162
+
163
+ The callback fires on the **union** of all habits.
164
+
165
+ ## CLI
166
+
167
+ The `habit` command runs any shell command on a randomized schedule. It works
168
+ two ways — attached, or managed by a lightweight background daemon.
169
+
170
+ **Attached** — fires in your terminal until you Ctrl-C:
171
+
172
+ ```sh
173
+ habit run --every "10s ~ 2s" -- echo "stretch"
174
+ ```
175
+
176
+ **Managed** — a background daemon keeps habits firing, and you list / inspect /
177
+ update / delete them like processes:
178
+
179
+ ```sh
180
+ habit start --name sync --every "1h ~ 5m" -- npm run sync # create + run in background
181
+ habit start --times 3 --per day --jitter 2h -- ./backup.sh
182
+ habit list # what's running, and what it runs
183
+ habit logs sync # recent output
184
+ habit stop sync # pause
185
+ habit start sync # resume
186
+ habit restart sync
187
+ habit update sync --every 30m # change schedule live
188
+ habit delete sync # remove (alias: rm)
189
+ habit kill # stop the daemon
190
+ ```
191
+
192
+ `habit list` shows each habit's id, name, status, schedule, **the command it
193
+ runs**, fire count, and next/last run:
194
+
195
+ ```
196
+ id name status schedule command runs next last
197
+ 1 sync running every 1h~5m npm run sync 4 in 52m 8m ago
198
+ ```
199
+
200
+ Habit definitions persist in `~/.habit/` (override with `HABIT_HOME`).
201
+
202
+ | Schedule flag | Meaning |
203
+ | --- | --- |
204
+ | `--every <dur>` | interval between fires |
205
+ | `--times <n> --per <period>` | N times per minute…year |
206
+ | `--jitter <dur>` | max random nudge per fire |
207
+ | `-i, --immediate` | fire once immediately |
208
+ | `--name <n>` | label for `list` / `logs` / etc. |
209
+
210
+ ## API
211
+
212
+ `createHabit(callback, options)` → `HabitController`
213
+
214
+ ```ts
215
+ interface HabitController {
216
+ readonly counter: number
217
+ readonly isActive: boolean
218
+ readonly nextRun: Date | null
219
+ start: (immediate?: boolean) => void
220
+ stop: () => void
221
+ pause: () => void
222
+ resume: () => void
223
+ reset: () => void // zero counter, restart if active
224
+ subscribe: (listener: () => void) => () => void
225
+ }
226
+
227
+ interface ControlFlags {
228
+ immediate?: boolean // fire once on start
229
+ autoStart?: boolean // default true (adapters gate this for SSR)
230
+ random?: () => number // inject a seeded RNG for determinism
231
+ }
232
+ ```
233
+
234
+ The framework adapters wrap this: Vue maps it onto refs, React onto state.
235
+
236
+ ### Managing habits
237
+
238
+ Every habit is registered, so you can list, look up, update, and remove them:
239
+
240
+ ```ts
241
+ import { createHabit, getHabit, listHabits } from 'habicron'
242
+
243
+ createHabit(syncFeed, { id: 'feed', name: 'Feed sync', every: '15m ~ 2m' })
244
+
245
+ listHabits() // [{ id, name, counter, nextRun, isActive, … }, …]
246
+ const job = getHabit('feed')
247
+ job?.update({ every: '5m' }) // reschedule in place (keeps id + counter)
248
+ job?.destroy() // stop and unregister
249
+ ```
250
+
251
+ In Vue and React, `useHabits()` returns that list **reactively** — a ready-made
252
+ management view that updates as habits fire or come and go:
253
+
254
+ ```ts
255
+ import { useHabits } from 'habicron/vue' // or 'habicron/react'
256
+
257
+ const habits = useHabits() // Vue: Ref<HabitSummary[]> · React: HabitSummary[]
258
+ ```
259
+
260
+ ## Scope
261
+
262
+ `habicron` is a **client/runtime scheduler**, not a durable job queue. It does
263
+ not provide persistence across reloads, at-least-once delivery, or distributed
264
+ coordination, and browser timers may be throttled in background tabs. For those,
265
+ reach for a server-side scheduler (e.g. a queue or Durable Object alarms).
266
+
267
+ ## Develop
268
+
269
+ ```sh
270
+ pnpm install
271
+ pnpm typecheck # tsc --noEmit
272
+ pnpm test # vitest run
273
+ pnpm prepack # unbuild → dist/ (ESM + CJS + .d.ts)
274
+ ```
275
+
276
+ ## License
277
+
278
+ [MIT](./LICENSE) © thecodeorigin
@@ -0,0 +1 @@
1
+ "use strict";const core_index=require("../core/index.cjs");function c(e){return{id:e.id,name:e.name,isActive:e.isActive,counter:e.counter,nextRun:e.nextRun}}function useHabit(e,i){const{onActive:s,onFire:o,onChange:r}=i,t=core_index.createHabit(e,{...i,autoStart:i.autoStart??typeof window<"u"});let n=t.isActive,a=t.counter;return t.subscribe(()=>{t.isActive!==n&&(n=t.isActive,s?.(t.isActive)),t.counter!==a&&(a=t.counter,o?.(t.counter)),r?.(c(t))}),s?.(t.isActive),r?.(c(t)),t}exports.clearHabits=core_index.clearHabits,exports.createHabit=core_index.createHabit,exports.getHabit=core_index.getHabit,exports.listHabits=core_index.listHabits,exports.subscribeHabits=core_index.subscribeHabits,exports.useHabit=useHabit;
@@ -0,0 +1,42 @@
1
+ import { HabitSummary, HabitOptions, HabitController } from '../core/index.cjs';
2
+ export { ControlFlags, Duration, Jitter, Period, Schedule, clearHabits, createHabit, getHabit, listHabits, subscribeHabits } from '../core/index.cjs';
3
+
4
+ /**
5
+ * habicron — browser adapter.
6
+ *
7
+ * Framework-agnostic reactivity for plain browser apps. Vanilla JS has no refs
8
+ * or component state, so habicron delivers state changes through callbacks
9
+ * instead: `onActive` (running state flipped), `onFire` (fired), and `onChange`
10
+ * (any change, with a snapshot). The scheduler is the same core engine.
11
+ *
12
+ * @example
13
+ * import { useHabit } from 'habicron/browser'
14
+ *
15
+ * useHabit(() => refreshWidget(), {
16
+ * every: '20s ~ 4s',
17
+ * onFire: count => badge.textContent = String(count),
18
+ * onActive: active => dot.classList.toggle('live', active),
19
+ * })
20
+ */
21
+
22
+ interface HabitCallbacks {
23
+ /** Called when the running state flips, and once on creation. */
24
+ onActive?: (isActive: boolean) => void;
25
+ /** Called after each fire, with the new total count. */
26
+ onFire?: (counter: number) => void;
27
+ /** Called on any state change, with a plain snapshot. */
28
+ onChange?: (summary: HabitSummary) => void;
29
+ }
30
+ /** Schedule + control flags, plus the browser reactivity callbacks. */
31
+ type UseHabitOptions = HabitOptions & HabitCallbacks;
32
+ /**
33
+ * Create a habit and wire its state changes to callbacks. Returns the
34
+ * {@link HabitController} so you can `pause`/`resume`/`update`/`destroy` it.
35
+ *
36
+ * SSR-safe: timers don't start unless a `window` is present (override with
37
+ * `autoStart`).
38
+ */
39
+ declare function useHabit(callback: () => void | Promise<void>, options: UseHabitOptions): HabitController;
40
+
41
+ export { HabitController, HabitOptions, HabitSummary, useHabit };
42
+ export type { HabitCallbacks, UseHabitOptions };
@@ -0,0 +1,42 @@
1
+ import { HabitSummary, HabitOptions, HabitController } from '../core/index.mjs';
2
+ export { ControlFlags, Duration, Jitter, Period, Schedule, clearHabits, createHabit, getHabit, listHabits, subscribeHabits } from '../core/index.mjs';
3
+
4
+ /**
5
+ * habicron — browser adapter.
6
+ *
7
+ * Framework-agnostic reactivity for plain browser apps. Vanilla JS has no refs
8
+ * or component state, so habicron delivers state changes through callbacks
9
+ * instead: `onActive` (running state flipped), `onFire` (fired), and `onChange`
10
+ * (any change, with a snapshot). The scheduler is the same core engine.
11
+ *
12
+ * @example
13
+ * import { useHabit } from 'habicron/browser'
14
+ *
15
+ * useHabit(() => refreshWidget(), {
16
+ * every: '20s ~ 4s',
17
+ * onFire: count => badge.textContent = String(count),
18
+ * onActive: active => dot.classList.toggle('live', active),
19
+ * })
20
+ */
21
+
22
+ interface HabitCallbacks {
23
+ /** Called when the running state flips, and once on creation. */
24
+ onActive?: (isActive: boolean) => void;
25
+ /** Called after each fire, with the new total count. */
26
+ onFire?: (counter: number) => void;
27
+ /** Called on any state change, with a plain snapshot. */
28
+ onChange?: (summary: HabitSummary) => void;
29
+ }
30
+ /** Schedule + control flags, plus the browser reactivity callbacks. */
31
+ type UseHabitOptions = HabitOptions & HabitCallbacks;
32
+ /**
33
+ * Create a habit and wire its state changes to callbacks. Returns the
34
+ * {@link HabitController} so you can `pause`/`resume`/`update`/`destroy` it.
35
+ *
36
+ * SSR-safe: timers don't start unless a `window` is present (override with
37
+ * `autoStart`).
38
+ */
39
+ declare function useHabit(callback: () => void | Promise<void>, options: UseHabitOptions): HabitController;
40
+
41
+ export { HabitController, HabitOptions, HabitSummary, useHabit };
42
+ export type { HabitCallbacks, UseHabitOptions };
@@ -0,0 +1,42 @@
1
+ import { HabitSummary, HabitOptions, HabitController } from '../core/index.js';
2
+ export { ControlFlags, Duration, Jitter, Period, Schedule, clearHabits, createHabit, getHabit, listHabits, subscribeHabits } from '../core/index.js';
3
+
4
+ /**
5
+ * habicron — browser adapter.
6
+ *
7
+ * Framework-agnostic reactivity for plain browser apps. Vanilla JS has no refs
8
+ * or component state, so habicron delivers state changes through callbacks
9
+ * instead: `onActive` (running state flipped), `onFire` (fired), and `onChange`
10
+ * (any change, with a snapshot). The scheduler is the same core engine.
11
+ *
12
+ * @example
13
+ * import { useHabit } from 'habicron/browser'
14
+ *
15
+ * useHabit(() => refreshWidget(), {
16
+ * every: '20s ~ 4s',
17
+ * onFire: count => badge.textContent = String(count),
18
+ * onActive: active => dot.classList.toggle('live', active),
19
+ * })
20
+ */
21
+
22
+ interface HabitCallbacks {
23
+ /** Called when the running state flips, and once on creation. */
24
+ onActive?: (isActive: boolean) => void;
25
+ /** Called after each fire, with the new total count. */
26
+ onFire?: (counter: number) => void;
27
+ /** Called on any state change, with a plain snapshot. */
28
+ onChange?: (summary: HabitSummary) => void;
29
+ }
30
+ /** Schedule + control flags, plus the browser reactivity callbacks. */
31
+ type UseHabitOptions = HabitOptions & HabitCallbacks;
32
+ /**
33
+ * Create a habit and wire its state changes to callbacks. Returns the
34
+ * {@link HabitController} so you can `pause`/`resume`/`update`/`destroy` it.
35
+ *
36
+ * SSR-safe: timers don't start unless a `window` is present (override with
37
+ * `autoStart`).
38
+ */
39
+ declare function useHabit(callback: () => void | Promise<void>, options: UseHabitOptions): HabitController;
40
+
41
+ export { HabitController, HabitOptions, HabitSummary, useHabit };
42
+ export type { HabitCallbacks, UseHabitOptions };
@@ -0,0 +1 @@
1
+ import{createHabit as s}from"../core/index.mjs";export{clearHabits,getHabit,listHabits,subscribeHabits}from"../core/index.mjs";function u(e){return{id:e.id,name:e.name,isActive:e.isActive,counter:e.counter,nextRun:e.nextRun}}function b(e,i){const{onActive:n,onFire:a,onChange:r}=i,t=s(e,{...i,autoStart:i.autoStart??typeof window<"u"});let c=t.isActive,o=t.counter;return t.subscribe(()=>{t.isActive!==c&&(c=t.isActive,n?.(t.isActive)),t.counter!==o&&(o=t.counter,a?.(t.counter)),r?.(u(t))}),n?.(t.isActive),r?.(u(t)),t}export{s as createHabit,b as useHabit};
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ "use strict";const node_child_process=require("node:child_process"),node_fs=require("node:fs"),u$1=require("node:process"),core_index=require("../core/index.cjs"),node_path=require("node:path"),node_os=require("node:os");function _interopDefaultCompat(e){return e&&typeof e=="object"&&"default"in e?e.default:e}const u__default=_interopDefaultCompat(u$1);function habitHome(){return u__default.env.HABIT_HOME??node_path.join(node_os.homedir(),".habit")}const p$1=()=>node_path.join(habitHome(),"habits.json"),g=()=>node_path.join(habitHome(),"state.json"),u=()=>node_path.join(habitHome(),"daemon.json"),logFile=e=>node_path.join(habitHome(),"logs",`${e}.log`);function d$1(e,t){try{return JSON.parse(node_fs.readFileSync(e,"utf8"))}catch{return t}}function m(e,t){node_fs.mkdirSync(habitHome(),{recursive:!0}),node_fs.writeFileSync(e,`${JSON.stringify(t,null,2)}
3
+ `)}function loadHabits(){return d$1(p$1(),[])}function saveHabits(e){m(p$1(),e)}function findHabit(e,t){return e.find(n=>n.id===t)??e.find(n=>n.name===t)}function j$1(e){const t=e.reduce((n,r)=>Math.max(n,Number(r.id)||0),0);return String(t+1)}function defaultName(e){const t=e.find(n=>/\.(?:[cm]?js|ts|sh|py|rb)$/.test(n))??e[0]??"habit";return node_path.basename(t).replace(/\.[^.]+$/,"")}function addHabit(e){const t=loadHabits(),n={id:j$1(t),name:e.name??defaultName(e.command),command:e.command,every:e.every,times:e.times,per:e.per,jitter:e.jitter,immediate:e.immediate,status:"running",rev:0,createdAt:new Date().toISOString()};return t.push(n),saveHabits(t),n}function patchHabit(e,t){const n=loadHabits(),r=findHabit(n,e);if(r)return Object.assign(r,t),saveHabits(n),r}function removeHabit(e){const t=loadHabits(),n=findHabit(t,e);if(!n)return;saveHabits(t.filter(i=>i!==n));const r=loadState();return delete r[n.id],saveState(r),n}function loadState(){return d$1(g(),{})}function saveState(e){m(g(),e)}const w={counter:0,lastRun:null,lastExit:null,nextRun:null,startedAt:null};function patchState(e,t){const n=loadState();n[e]={...w,...n[e],...t},saveState(n)}function readDaemon(){return d$1(u(),null)}function writeDaemon(e){m(u(),e)}function clearDaemon(){node_fs.existsSync(u())&&m(u(),null)}function daemonAlive(){const e=readDaemon();if(e?.pid==null)return!1;try{return u__default.kill(e.pid,0),!0}catch{return!1}}function recordToOptions(e){const t={id:e.id,name:e.name,immediate:e.immediate,autoStart:!1};if(e.every!=null)return{...t,every:e.every,...e.jitter!=null?{jitter:e.jitter}:{}};if(e.times!=null&&e.per!=null)return{...t,times:e.times,per:e.per,...e.jitter!=null?{jitter:e.jitter}:{}};throw new Error(`habit "${e.id}" has no schedule (every, or times + per)`)}function scheduleLabel(e){if(e.every!=null)return`every ${e.every}`;const t=`${e.times}\xD7/${e.per}`;return e.jitter!=null?`${t} ~ ${e.jitter}`:t}function $$1(e){if(e==null)return"\u2014";const t=Date.now()-new Date(e).getTime();return t<0?`in ${f(-t)}`:`${f(t)} ago`}function f(e){const t=Math.round(e/1e3);if(t<60)return`${t}s`;const n=Math.round(t/60);if(n<60)return`${n}m`;const r=Math.round(n/60);return r<24?`${r}h`:`${Math.round(r/24)}d`}function S(e,t){if(t==="stopped"||e==null)return"\u2014";const n=new Date(e).getTime()-Date.now();return n<=0?"now":`in ${f(n)}`}function formatList(e,t){if(e.length===0)return'No habits yet. Create one with: habit start --every "1h ~ 5m" -- <command>';const n=["id","name","status","schedule","command","runs","next","last"],r=e.map(c=>{const s=t[c.id];return[c.id,c.name,c.status,scheduleLabel(c),c.command.join(" "),String(s?.counter??0),S(s?.nextRun??null,c.status),$$1(s?.lastRun??null)]}),i=n.map((c,s)=>Math.max(c.length,...r.map(b=>b[s].length))),l=c=>c.map((s,b)=>s.padEnd(i[b])).join(" ").trimEnd();return[l(n),l(i.map(c=>"\u2500".repeat(c))),...r.map(l)].join(`
4
+ `)}function d(e,t){const n=logFile(e);node_fs.mkdirSync(node_path.dirname(n),{recursive:!0}),node_fs.appendFileSync(n,t)}async function x$1(e){return new Promise(t=>{const n=new Date;d(e.id,`
5
+ [${n.toISOString()}] $ ${e.command.join(" ")}
6
+ `);let r;try{const[i,...l]=e.command;r=node_child_process.spawn(i,l,{stdio:["ignore","pipe","pipe"],shell:!1})}catch(i){const l=i instanceof Error?i.message:String(i);d(e.id,`[habit] spawn failed: ${l}
7
+ `),patchState(e.id,{lastRun:n.toISOString(),lastExit:null}),t();return}r.stdout?.on("data",i=>d(e.id,i)),r.stderr?.on("data",i=>d(e.id,i)),r.on("error",i=>{d(e.id,`[habit] ${i.message}
8
+ `),patchState(e.id,{lastRun:n.toISOString(),lastExit:null}),t()}),r.on("close",i=>{patchState(e.id,{lastRun:n.toISOString(),lastExit:i}),t()})})}function runDaemon(){writeDaemon({pid:u__default.pid,startedAt:new Date().toISOString()});const e=new Map,t=s=>{const b=core_index.createHabit(async()=>x$1(s),recordToOptions(s));return b.subscribe(()=>{patchState(s.id,{counter:b.counter,nextRun:b.nextRun?.toISOString()??null})}),patchState(s.id,{startedAt:new Date().toISOString()}),b.start(s.immediate??!1),{ctrl:b,rev:s.rev}},n=s=>{const b=e.get(s);b&&(b.ctrl.destroy(),e.delete(s),patchState(s,{nextRun:null,startedAt:null}))},r=()=>{const s=loadHabits(),b=new Set(s.map(v=>v.id));for(const v of[...e.keys()])b.has(v)||n(v);for(const v of s){const y=e.get(v.id);if(v.status!=="running"){y&&n(v.id);continue}y?y.rev!==v.rev&&(n(v.id),e.set(v.id,t(v))):e.set(v.id,t(v))}};r();const i=setInterval(r,1e3),l=()=>{clearInterval(i);for(const s of[...e.keys()])n(s);clearDaemon(),u__default.exit(0)};u__default.on("SIGTERM",l),u__default.on("SIGINT",l);const c=loadState();for(const s of Object.keys(c))e.has(s)||patchState(s,{nextRun:null,startedAt:null})}const VERSION="0.2.0",$=["minute","hour","day","week","month","year"],C=$;function A(e){return e!=null&&C.includes(e)}function parseArgs(e){const t={immediate:!1,help:!1,version:!1,command:[]};for(let n=0;n<e.length;n++){const r=e[n];if(r==="--"){t.command=e.slice(n+1);break}switch(r){case"-h":case"--help":t.help=!0;break;case"-v":case"--version":t.version=!0;break;case"-i":case"--immediate":t.immediate=!0;break;case"--name":t.name=e[++n];break;case"--every":t.every=e[++n];break;case"--times":{const i=Number(e[++n]);if(!Number.isFinite(i)||i<=0)return{error:`--times expects a positive number, got "${e[n]}"`};t.times=i;break}case"--per":{const i=e[++n];if(!A(i))return{error:`--per expects one of ${$.join(", ")}`};t.per=i;break}case"--jitter":t.jitter=e[++n];break;case"--max":{const i=Number(e[++n]);if(!Number.isFinite(i)||i<=0)return{error:`--max expects a positive number, got "${e[n]}"`};t.max=i;break}case"--exec":t.command=e.slice(n+1),n=e.length;break;default:if(r.startsWith("-"))return{error:`unknown option "${r}"`};t.command=e.slice(n),n=e.length}}return{args:t}}function toOptions(e){let t=null;return e.every!=null?t={every:e.every,...e.jitter!=null?{jitter:e.jitter}:{}}:e.times!=null&&e.per!=null&&(t={times:e.times,per:e.per,...e.jitter!=null?{jitter:e.jitter}:{}}),t?{options:{...t,immediate:e.immediate,autoStart:!1}}:{error:"a schedule is required: use --every <dur> or --times <n> --per <period>"}}function D(e){return e.command.length===0?{error:'no command given. Pass one after "--".'}:e.every==null&&(e.times==null||e.per==null)?{error:"a schedule is required: --every <dur> or --times <n> --per <period>"}:{habit:{name:e.name,command:e.command,every:e.every,times:e.times,per:e.per,jitter:e.jitter,immediate:e.immediate}}}const HELP=`habit v${VERSION} \u2014 randomized recurring schedules ("habits", not cronjobs)
9
+
10
+ Usage:
11
+ habit start [--name <n>] <schedule> -- <command...> create + run in the background
12
+ habit run <schedule> -- <command...> run attached (Ctrl-C to stop)
13
+ habit list list habits and what they run
14
+ habit stop <id|name|all> pause
15
+ habit start <id|name> resume a paused habit
16
+ habit restart <id|name> restart
17
+ habit update <id|name> [--every \u2026 | --name \u2026 | -- <command...>]
18
+ habit delete <id|name|all> remove (alias: rm)
19
+ habit logs <id|name> [-n <lines>] show recent output
20
+ habit kill stop the background daemon
21
+
22
+ Schedule:
23
+ --every <dur> interval, e.g. "2h", "10s ~ 2s", "1h30m"
24
+ --times <n> --per <period> N times per minute|hour|day|week|month|year
25
+ --jitter <dur> max random nudge per fire, e.g. "5m"
26
+ -i, --immediate fire once immediately on start
27
+
28
+ Examples:
29
+ habit start --every "10s ~ 2s" -- echo "stretch"
30
+ habit start --name sync --times 3 --per hour --jitter 5m -- npm run sync
31
+ habit list
32
+ `;function a(e){u__default.stdout.write(`${e}
33
+ `)}function o(e){u__default.stderr.write(`[habit] ${e}
34
+ `)}function h(){if(daemonAlive())return;const e=u__default.argv[1];node_child_process.spawn(u__default.execPath,[e,"__daemon"],{detached:!0,stdio:"ignore"}).unref()}function p(e){return`${e.id} (${e.name}) \u2192 ${e.command.join(" ")}`}function I(e){if(e.length===1&&!e[0].startsWith("-")){const c=findHabit(loadHabits(),e[0]);if(c)return patchHabit(c.id,{status:"running",rev:c.rev+1}),h(),a(`resumed ${p(c)}`),0}const{args:t,error:n}=parseArgs(e);if(n!=null||!t)return o(n??"parse error"),1;const{habit:r,error:i}=D(t);if(i!=null||!r)return o(i??"invalid habit"),1;const l=addHabit(r);return h(),a(`started ${p(l)}`),a(` ${t.every!=null?`every ${t.every}`:`${t.times}\xD7/${t.per}`}${t.jitter!=null?` ~ ${t.jitter}`:""}`),0}function x(e,t){const n=loadHabits();let r;if(e==="all")r=n;else{const i=findHabit(n,e);r=i?[i]:[]}if(r.length===0)return o(`no habit matching "${e}"`),1;for(const i of r)t(i);return 0}function T(e){return e[0]?x(e[0],t=>{patchHabit(t.id,{status:"stopped"}),a(`stopped ${t.id} (${t.name})`)}):(o("usage: habit stop <id|name|all>"),1)}function L(e){if(!e[0])return o("usage: habit restart <id|name|all>"),1;const t=x(e[0],n=>{patchHabit(n.id,{status:"running",rev:n.rev+1}),a(`restarted ${n.id} (${n.name})`)});return t===0&&h(),t}function F(e){if(!e[0])return o("usage: habit delete <id|name|all>"),1;if(e[0]==="all"){const n=loadHabits();if(n.length===0)return o("no habits to delete"),1;for(const r of n)removeHabit(r.id);return a(`deleted ${n.length} habit(s)`),0}const t=removeHabit(e[0]);return t?(a(`deleted ${t.id} (${t.name})`),0):(o(`no habit matching "${e[0]}"`),1)}function W(e){const t=e[0];if(!t||t.startsWith("-"))return o("usage: habit update <id|name> [--every \u2026 | --name \u2026 | -- <command...>]"),1;const n=findHabit(loadHabits(),t);if(!n)return o(`no habit matching "${t}"`),1;const{args:r,error:i}=parseArgs(e.slice(1));if(i!=null||!r)return o(i??"parse error"),1;const l={rev:n.rev+1};r.name!=null&&(l.name=r.name),r.every!=null&&(l.every=r.every,l.times=void 0,l.per=void 0),r.times!=null&&(l.times=r.times),r.per!=null&&(l.per=r.per),r.jitter!=null&&(l.jitter=r.jitter),r.immediate&&(l.immediate=!0),r.command.length>0&&(l.command=r.command);const c=patchHabit(n.id,l);return c?(h(),a(`updated ${p(c)}`),0):(o(`no habit matching "${t}"`),1)}function _(){a(formatList(loadHabits(),loadState()));const e=readDaemon();return a(""),a(daemonAlive()&&e?`daemon: running (pid ${e.pid})`:"daemon: not running"),0}function M(e){let t,n=50;for(let i=0;i<e.length;i++)e[i]==="-n"?n=Math.max(1,Number(e[++i])||50):!e[i].startsWith("-")&&t==null&&(t=e[i]);if(t==null)return o("usage: habit logs <id|name> [-n <lines>]"),1;const r=findHabit(loadHabits(),t);if(!r)return o(`no habit matching "${t}"`),1;try{const i=node_fs.readFileSync(logFile(r.id),"utf8").trimEnd().split(`
35
+ `).slice(-n).join(`
36
+ `);a(i||"(no output yet)")}catch{a("(no output yet)")}return 0}function O(){const e=readDaemon();if(e?.pid==null||!daemonAlive())return a("daemon: not running"),0;try{u__default.kill(e.pid,"SIGTERM"),a(`stopped daemon (pid ${e.pid})`)}catch(t){return o(`could not stop daemon: ${t instanceof Error?t.message:String(t)}`),1}return 0}async function j(e){const{args:t,error:n}=parseArgs(e);if(n!=null||!t)return o(n??"parse error"),1;if(t.command.length===0)return o('no command given. Pass one after "--".'),1;const{options:r,error:i}=toOptions(t);return i!=null||!r?(o(i??"invalid schedule"),1):(await new Promise(l=>{const c=core_index.createHabit(async()=>{const b=new Date().toLocaleTimeString();return a(`[habit] ${b} \u2192 ${t.command.join(" ")}`),G(t.command)},r);c.start(t.immediate),c.nextRun&&a(`[habit] first run at ${c.nextRun.toLocaleTimeString()}`);const s=()=>{c.stop(),a(`
37
+ [habit] stopped`),l()};u__default.on("SIGINT",s),u__default.on("SIGTERM",s)}),0)}async function G(e){return new Promise(t=>{const[n,...r]=e,i=node_child_process.spawn(n,r,{stdio:"inherit",shell:!1});i.on("error",l=>o(`command failed: ${l.message}`)),i.on("close",()=>t())})}async function main(e){const[t,...n]=e;if(t==null||t==="-h"||t==="--help")return u__default.stdout.write(HELP),0;if(t==="-v"||t==="--version")return a(VERSION),0;switch(t){case"__daemon":return runDaemon(),new Promise(()=>{});case"run":return j(n);case"start":return I(n);case"stop":return T(n);case"restart":return L(n);case"update":return W(n);case"delete":case"rm":return F(n);case"list":case"ls":return _();case"logs":return M(n);case"kill":return O();default:return t.startsWith("-")?j(e):(o(`unknown command "${t}". Try: habit --help`),1)}}const q=typeof u__default<"u"&&u__default.argv[1]!=null&&/habit|cli[\\/]index/.test(u__default.argv[1]);q&&(u__default.stdout.on("error",e=>{e.code==="EPIPE"&&u__default.exit(0)}),main(u__default.argv.slice(2)).then(e=>{u__default.exitCode=e})),exports.HELP=HELP,exports.VERSION=VERSION,exports.main=main,exports.parseArgs=parseArgs,exports.toOptions=toOptions;
@@ -0,0 +1,35 @@
1
+ import { Period, Schedule } from '../core/index.cjs';
2
+
3
+ declare const VERSION = "0.2.0";
4
+ interface CliArgs {
5
+ name?: string;
6
+ every?: string;
7
+ times?: number;
8
+ per?: Period;
9
+ jitter?: string;
10
+ immediate: boolean;
11
+ max?: number;
12
+ help: boolean;
13
+ version: boolean;
14
+ command: string[];
15
+ }
16
+ interface ParseResult {
17
+ args?: CliArgs;
18
+ error?: string;
19
+ }
20
+ /** Parse schedule flags + command (the bit shared by `run`/`start`/`update`). */
21
+ declare function parseArgs(argv: string[]): ParseResult;
22
+ /** Build core {@link Schedule}-based options for `run` (foreground). */
23
+ declare function toOptions(args: CliArgs): {
24
+ options?: Schedule & {
25
+ immediate?: boolean;
26
+ autoStart?: boolean;
27
+ };
28
+ error?: string;
29
+ };
30
+ declare const HELP = "habit v0.2.0 \u2014 randomized recurring schedules (\"habits\", not cronjobs)\n\nUsage:\n habit start [--name <n>] <schedule> -- <command...> create + run in the background\n habit run <schedule> -- <command...> run attached (Ctrl-C to stop)\n habit list list habits and what they run\n habit stop <id|name|all> pause\n habit start <id|name> resume a paused habit\n habit restart <id|name> restart\n habit update <id|name> [--every \u2026 | --name \u2026 | -- <command...>]\n habit delete <id|name|all> remove (alias: rm)\n habit logs <id|name> [-n <lines>] show recent output\n habit kill stop the background daemon\n\nSchedule:\n --every <dur> interval, e.g. \"2h\", \"10s ~ 2s\", \"1h30m\"\n --times <n> --per <period> N times per minute|hour|day|week|month|year\n --jitter <dur> max random nudge per fire, e.g. \"5m\"\n -i, --immediate fire once immediately on start\n\nExamples:\n habit start --every \"10s ~ 2s\" -- echo \"stretch\"\n habit start --name sync --times 3 --per hour --jitter 5m -- npm run sync\n habit list\n";
31
+ /** CLI entry. Returns the intended exit code (daemon never returns). */
32
+ declare function main(argv: string[]): Promise<number>;
33
+
34
+ export { HELP, VERSION, main, parseArgs, toOptions };
35
+ export type { CliArgs, ParseResult };
@@ -0,0 +1,35 @@
1
+ import { Period, Schedule } from '../core/index.mjs';
2
+
3
+ declare const VERSION = "0.2.0";
4
+ interface CliArgs {
5
+ name?: string;
6
+ every?: string;
7
+ times?: number;
8
+ per?: Period;
9
+ jitter?: string;
10
+ immediate: boolean;
11
+ max?: number;
12
+ help: boolean;
13
+ version: boolean;
14
+ command: string[];
15
+ }
16
+ interface ParseResult {
17
+ args?: CliArgs;
18
+ error?: string;
19
+ }
20
+ /** Parse schedule flags + command (the bit shared by `run`/`start`/`update`). */
21
+ declare function parseArgs(argv: string[]): ParseResult;
22
+ /** Build core {@link Schedule}-based options for `run` (foreground). */
23
+ declare function toOptions(args: CliArgs): {
24
+ options?: Schedule & {
25
+ immediate?: boolean;
26
+ autoStart?: boolean;
27
+ };
28
+ error?: string;
29
+ };
30
+ declare const HELP = "habit v0.2.0 \u2014 randomized recurring schedules (\"habits\", not cronjobs)\n\nUsage:\n habit start [--name <n>] <schedule> -- <command...> create + run in the background\n habit run <schedule> -- <command...> run attached (Ctrl-C to stop)\n habit list list habits and what they run\n habit stop <id|name|all> pause\n habit start <id|name> resume a paused habit\n habit restart <id|name> restart\n habit update <id|name> [--every \u2026 | --name \u2026 | -- <command...>]\n habit delete <id|name|all> remove (alias: rm)\n habit logs <id|name> [-n <lines>] show recent output\n habit kill stop the background daemon\n\nSchedule:\n --every <dur> interval, e.g. \"2h\", \"10s ~ 2s\", \"1h30m\"\n --times <n> --per <period> N times per minute|hour|day|week|month|year\n --jitter <dur> max random nudge per fire, e.g. \"5m\"\n -i, --immediate fire once immediately on start\n\nExamples:\n habit start --every \"10s ~ 2s\" -- echo \"stretch\"\n habit start --name sync --times 3 --per hour --jitter 5m -- npm run sync\n habit list\n";
31
+ /** CLI entry. Returns the intended exit code (daemon never returns). */
32
+ declare function main(argv: string[]): Promise<number>;
33
+
34
+ export { HELP, VERSION, main, parseArgs, toOptions };
35
+ export type { CliArgs, ParseResult };
@@ -0,0 +1,35 @@
1
+ import { Period, Schedule } from '../core/index.js';
2
+
3
+ declare const VERSION = "0.2.0";
4
+ interface CliArgs {
5
+ name?: string;
6
+ every?: string;
7
+ times?: number;
8
+ per?: Period;
9
+ jitter?: string;
10
+ immediate: boolean;
11
+ max?: number;
12
+ help: boolean;
13
+ version: boolean;
14
+ command: string[];
15
+ }
16
+ interface ParseResult {
17
+ args?: CliArgs;
18
+ error?: string;
19
+ }
20
+ /** Parse schedule flags + command (the bit shared by `run`/`start`/`update`). */
21
+ declare function parseArgs(argv: string[]): ParseResult;
22
+ /** Build core {@link Schedule}-based options for `run` (foreground). */
23
+ declare function toOptions(args: CliArgs): {
24
+ options?: Schedule & {
25
+ immediate?: boolean;
26
+ autoStart?: boolean;
27
+ };
28
+ error?: string;
29
+ };
30
+ declare const HELP = "habit v0.2.0 \u2014 randomized recurring schedules (\"habits\", not cronjobs)\n\nUsage:\n habit start [--name <n>] <schedule> -- <command...> create + run in the background\n habit run <schedule> -- <command...> run attached (Ctrl-C to stop)\n habit list list habits and what they run\n habit stop <id|name|all> pause\n habit start <id|name> resume a paused habit\n habit restart <id|name> restart\n habit update <id|name> [--every \u2026 | --name \u2026 | -- <command...>]\n habit delete <id|name|all> remove (alias: rm)\n habit logs <id|name> [-n <lines>] show recent output\n habit kill stop the background daemon\n\nSchedule:\n --every <dur> interval, e.g. \"2h\", \"10s ~ 2s\", \"1h30m\"\n --times <n> --per <period> N times per minute|hour|day|week|month|year\n --jitter <dur> max random nudge per fire, e.g. \"5m\"\n -i, --immediate fire once immediately on start\n\nExamples:\n habit start --every \"10s ~ 2s\" -- echo \"stretch\"\n habit start --name sync --times 3 --per hour --jitter 5m -- npm run sync\n habit list\n";
31
+ /** CLI entry. Returns the intended exit code (daemon never returns). */
32
+ declare function main(argv: string[]): Promise<number>;
33
+
34
+ export { HELP, VERSION, main, parseArgs, toOptions };
35
+ export type { CliArgs, ParseResult };