smart-spinner 1.0.1 → 1.1.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/.ocmux.json +1 -0
- package/AGENTS.md +19 -0
- package/README.md +25 -12
- package/examples/timestamp-demo.js +31 -0
- package/index.js +61 -5
- package/package.json +1 -1
- package/smart-spinner-tasks.md +191 -0
package/.ocmux.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"url":"http://127.0.0.1:33469","logfile":"/tmp/opencode-serve-3370b8294dc3.log","window_index":4}
|
package/AGENTS.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# smart-spinner — agent guide
|
|
2
|
+
|
|
3
|
+
Single-file npm package (`index.js`). No build, no tests, no CI.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
- **Run examples:** `node examples/simple-example.js` (from project root; uses `require("../")`)
|
|
8
|
+
- **Test:** none — `npm test` is a no-op stub
|
|
9
|
+
|
|
10
|
+
## Key architecture
|
|
11
|
+
|
|
12
|
+
- `spinner.create(msg, opts)` → returns `ctrlAPI(stopOrMsg, newMsg?)` — `opts` is an options object (`{ spinners, timestamp }`). For backward compat, a string/array is accepted as shorthand for `{ spinners: value }`. The returned API is overloaded: pass a `boolean` to stop, a `string`/`function` to update the message, or both. Also has `ctrlAPI.update(newMsg)` for non-TTY phase transitions.
|
|
13
|
+
- `opts.timestamp` (`'utc'`, `'local'`, or IANA timezone) prepends ISO-8601 timestamps to non-TTY log lines.
|
|
14
|
+
- Non-TTY detection (`tty.isatty`) silences animation; only the initial message + updates are `console.log`-ed once.
|
|
15
|
+
- `has-unicode` / `chalk` graceful fallback for non-Unicode / no-color terminals.
|
|
16
|
+
|
|
17
|
+
## Dependencies (old majors)
|
|
18
|
+
|
|
19
|
+
`chalk@^2`, `cli-spinners@^1`, `log-update@^2`, `has-unicode@^2` — be aware if bumping.
|
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ const spinner = require("smart-spinner");
|
|
|
84
84
|
|
|
85
85
|
|
|
86
86
|
```javascript
|
|
87
|
-
var progress = spinner.create(<message> [, <
|
|
87
|
+
var progress = spinner.create(<message> [, <opts>]);
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
**Where:**
|
|
@@ -94,25 +94,38 @@ var progress = spinner.create(<message> [, <spinner_list>]);
|
|
|
94
94
|
is actually connected to terminal or not. So complex calculations can be
|
|
95
95
|
avoided if needed (also callback will be called single time in this case).
|
|
96
96
|
|
|
97
|
-
* `<
|
|
98
|
-
[demo()](#demo) to know which ones are available) or string "all" to use
|
|
99
|
-
all.
|
|
100
|
-
- Specified spinners will be rotated regularly.
|
|
101
|
-
- If "all" keyword is specified instead, all available ones will be used.
|
|
102
|
-
- If "demo" keyword is specified instead, it will operate just like if
|
|
103
|
-
[demo()](#demo) were used instead.
|
|
104
|
-
- If string (distinct of "all" and "demo" is used) it wil be threated as
|
|
105
|
-
single spinner list.
|
|
97
|
+
* `<opts>`: Optional options object with the following properties:
|
|
106
98
|
|
|
99
|
+
- `spinners` (string|Array): Spinner name or list of spinner names (See
|
|
100
|
+
[list()](#list) or [demo()](#demo) to know which ones are available).
|
|
101
|
+
Use `"all"` to rotate through all available spinners, or `"demo"` to
|
|
102
|
+
show spinner names as they rotate. Default: `['dots']`.
|
|
107
103
|
|
|
108
|
-
|
|
104
|
+
- `timestamp` (boolean|string): Prepend ISO-8601 timestamp to non-TTY
|
|
105
|
+
log lines. Use `'utc'` (or `true`) for UTC, `'local'` for local
|
|
106
|
+
timezone, or any IANA timezone string (e.g. `'America/New_York'`).
|
|
107
|
+
Default: none.
|
|
109
108
|
|
|
110
|
-
|
|
109
|
+
> **Note:** For backward compatibility, the second parameter also accepts a
|
|
110
|
+
> string or array directly as a shorthand for `{ spinners: <value> }`.
|
|
111
|
+
> The old 3-argument form `create(msg, spinners, opts)` is deprecated.
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
**Return value:** Control API with the following methods:
|
|
115
|
+
|
|
116
|
+
__Function call syntax:__
|
|
111
117
|
|
|
112
118
|
* `progress (String <newMsg>)`: Set message to provided string or callback.
|
|
113
119
|
* `progress (Boolean <stop>)`: Stop spinning with success (stop = true) or failed (stop = false).
|
|
114
120
|
* `progress (Boolean <stop>, String <newMsg>)` Stop spinning but also updates message.
|
|
115
121
|
|
|
122
|
+
__Method syntax:__
|
|
123
|
+
|
|
124
|
+
* `progress.update(String|Function <newMsg>)`: Replace the message callback
|
|
125
|
+
and immediately emit a new line in non-TTY mode (does nothing on
|
|
126
|
+
interactive terminals — the animation loop picks up the new callback
|
|
127
|
+
automatically).
|
|
128
|
+
|
|
116
129
|
|
|
117
130
|
|
|
118
131
|
### Other functions
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Run me with 'node path/to/examples/simple-example.js'.
|
|
3
|
+
|
|
4
|
+
const spinner = require("../");
|
|
5
|
+
|
|
6
|
+
var progress = spinner.create("Here a Spinner with timestamp", {timestamp: "local"});
|
|
7
|
+
|
|
8
|
+
// Also try using demo() instead:
|
|
9
|
+
// var progress = spinner.demo();
|
|
10
|
+
|
|
11
|
+
setTimeout(()=>{
|
|
12
|
+
progress("If you don't see it, try to pipe the output to a file or a command like `cat`.");
|
|
13
|
+
}, 2000);
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
setTimeout(()=>{
|
|
17
|
+
progress(function(){
|
|
18
|
+
var someStatus = (new Date()).toLocaleString();
|
|
19
|
+
return "You already know that current time is: " + someStatus;
|
|
20
|
+
});
|
|
21
|
+
}, 4000);
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
setTimeout(()=>{
|
|
25
|
+
progress("But if you read a logged file later, you won't.");
|
|
26
|
+
}, 6000);
|
|
27
|
+
|
|
28
|
+
setTimeout(()=>Math.round(Math.random())
|
|
29
|
+
? progress(true, "Job done!!")
|
|
30
|
+
: progress(false, "Error!!")
|
|
31
|
+
, 8000);
|
package/index.js
CHANGED
|
@@ -39,7 +39,14 @@ if (
|
|
|
39
39
|
// ===================//}}}
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
function smartSpinner(cbk,
|
|
42
|
+
function smartSpinner(cbk, opts = {}) {//{{{
|
|
43
|
+
|
|
44
|
+
// Backward compat: if opts is a string or array, treat as spinner list:
|
|
45
|
+
if (typeof opts === 'string' || Array.isArray(opts)) {
|
|
46
|
+
opts = { spinners: opts };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
var spinners = opts.spinners !== undefined ? opts.spinners : ['dots'];
|
|
43
50
|
|
|
44
51
|
var messageCbk;
|
|
45
52
|
|
|
@@ -49,6 +56,45 @@ function smartSpinner(cbk, spinners = ['dots']) {//{{{
|
|
|
49
56
|
let next;
|
|
50
57
|
let is_tty = tty.isatty(process.stdout.fd);
|
|
51
58
|
|
|
59
|
+
var tsEnabled = !!(opts && opts.timestamp);
|
|
60
|
+
var tsOpt = opts && opts.timestamp;
|
|
61
|
+
if (tsEnabled) {
|
|
62
|
+
if (tsOpt === true) tsOpt = 'utc';
|
|
63
|
+
if (typeof tsOpt !== 'string') tsOpt = 'utc';
|
|
64
|
+
};
|
|
65
|
+
function fmtTS() {
|
|
66
|
+
if (!tsEnabled) return '';
|
|
67
|
+
var now = new Date();
|
|
68
|
+
var s;
|
|
69
|
+
if (tsOpt === 'utc') {
|
|
70
|
+
s = now.toISOString().replace(/\.\d+Z$/, 'Z');
|
|
71
|
+
} else {
|
|
72
|
+
var dtfOpt = {
|
|
73
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
74
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
75
|
+
hour12: false, timeZoneName: 'shortOffset'
|
|
76
|
+
};
|
|
77
|
+
if (tsOpt !== 'local') dtfOpt.timeZone = tsOpt;
|
|
78
|
+
var map = {};
|
|
79
|
+
Intl.DateTimeFormat('en-CA', dtfOpt).formatToParts(now).forEach(function(p) { map[p.type] = p.value; });
|
|
80
|
+
var tz = map.timeZoneName || 'Z';
|
|
81
|
+
if (tz === 'UTC') tz = 'Z';
|
|
82
|
+
else if (tz.slice(0, 3) === 'GMT') {
|
|
83
|
+
var off = tz.slice(3);
|
|
84
|
+
if (!off) { tz = 'Z'; }
|
|
85
|
+
else {
|
|
86
|
+
var sign = off.charAt(0);
|
|
87
|
+
var rest = off.slice(1);
|
|
88
|
+
var parts = rest.split(':');
|
|
89
|
+
var hh = parts[0].length < 2 ? '0' + parts[0] : parts[0];
|
|
90
|
+
var mm = parts[1] ? (parts[1].length < 2 ? '0' + parts[1] : parts[1]) : '00';
|
|
91
|
+
tz = sign + hh + ':' + mm;
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
s = map.year + '-' + map.month + '-' + map.day + 'T' + map.hour + ':' + map.minute + ':' + map.second + tz;
|
|
95
|
+
};
|
|
96
|
+
return '[' + s + '] ';
|
|
97
|
+
};
|
|
52
98
|
|
|
53
99
|
function showNextFrame () {//{{{
|
|
54
100
|
const frames = cliSpinners[spinners[spinner]].frames;
|
|
@@ -77,7 +123,7 @@ function smartSpinner(cbk, spinners = ['dots']) {//{{{
|
|
|
77
123
|
if (stop !== undefined) {
|
|
78
124
|
stop || process.stdout.write("\n");
|
|
79
125
|
} else {
|
|
80
|
-
console.log(symbols.arrow + ' ' + messageCbk(false));
|
|
126
|
+
console.log(fmtTS() + symbols.arrow + ' ' + messageCbk(false));
|
|
81
127
|
};
|
|
82
128
|
};
|
|
83
129
|
};
|
|
@@ -88,15 +134,25 @@ function smartSpinner(cbk, spinners = ['dots']) {//{{{
|
|
|
88
134
|
stopped = true;
|
|
89
135
|
|
|
90
136
|
let s = symbols[stop ? "ok" : "error"];
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
137
|
+
if (is_tty) {
|
|
138
|
+
logUpdate(s + ' ' + messageCbk(true));
|
|
139
|
+
process.stdout.write("\n");
|
|
140
|
+
} else {
|
|
141
|
+
console.log(fmtTS() + s + ' ' + messageCbk(true));
|
|
142
|
+
};
|
|
94
143
|
}
|
|
95
144
|
|
|
96
145
|
return true; // Success.
|
|
97
146
|
|
|
98
147
|
};//}}}
|
|
99
148
|
|
|
149
|
+
ctrlAPI.update = function (newCbk) {
|
|
150
|
+
messageCbk = ("function" == typeof newCbk) ? newCbk : ()=>newCbk;
|
|
151
|
+
if (!is_tty) {
|
|
152
|
+
console.log(fmtTS() + symbols.arrow + ' ' + messageCbk(false));
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
100
156
|
ctrlAPI(cbk);
|
|
101
157
|
|
|
102
158
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# smart-spinner: pending modifications
|
|
2
|
+
|
|
3
|
+
## Task 1: Remove `\r` control characters from non-TTY output on stop
|
|
4
|
+
|
|
5
|
+
**Location:** `index.js`, stop block (lines ~86–94)
|
|
6
|
+
|
|
7
|
+
**Problem:** When output is not a TTY (e.g. `cmt_import | tee log`), the stop
|
|
8
|
+
path calls `logUpdate()` unconditionally. `logUpdate` writes `\r` to overwrite
|
|
9
|
+
the current line, which is fine on a terminal but leaves literal `\r` garbage
|
|
10
|
+
in log files when viewed in editors like vim (though `cat`/`less` handle them).
|
|
11
|
+
|
|
12
|
+
**Fix:** In the stop block (`if (stop !== undefined) { ... }`), branch on
|
|
13
|
+
`is_tty`:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
if (is_tty) {
|
|
17
|
+
logUpdate(s + ' ' + messageCbk(true));
|
|
18
|
+
process.stdout.write('\n');
|
|
19
|
+
} else {
|
|
20
|
+
console.log(s + ' ' + messageCbk(true));
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
This mirrors the existing non-TTY branch already used in the update path
|
|
25
|
+
(around line 80): `console.log` writes plain text with a final newline, no
|
|
26
|
+
`\r`.
|
|
27
|
+
|
|
28
|
+
## Task 2: Expose an `update()` method so the client can trigger a new non-TTY log line
|
|
29
|
+
|
|
30
|
+
**Location:** `index.js`, the control API returned by `spinner.create()`
|
|
31
|
+
|
|
32
|
+
**Problem:** Smart-spinner's `create(fn)` takes a single callback. When the
|
|
33
|
+
client's internal phase changes (e.g. from md5-computation to XML-loading),
|
|
34
|
+
the client needs the spinner to emit a fresh progress line in non-TTY mode.
|
|
35
|
+
Currently, the non-TTY `console.log` only fires when the callback is first
|
|
36
|
+
set (line ~80), so phase transitions stay silent — the log never shows the
|
|
37
|
+
new phase message.
|
|
38
|
+
|
|
39
|
+
The client workaround is to stop and re-create the spinner, which is
|
|
40
|
+
cumbersome and loses the visual continuity.
|
|
41
|
+
|
|
42
|
+
**Fix:** Add an `update()` method to the control API that:
|
|
43
|
+
1. Replaces the internal callback reference.
|
|
44
|
+
2. If `!is_tty`, immediately calls `console.log(messageCbk(false))` to emit
|
|
45
|
+
the new message.
|
|
46
|
+
|
|
47
|
+
Signature: `ctrlAPI.update(newCallback)` — `newCallback` replaces the
|
|
48
|
+
original callback and a non-TTY line is written synchronously.
|
|
49
|
+
|
|
50
|
+
If `is_tty` (interactive terminal), `update()` does nothing — the animation
|
|
51
|
+
loop already picks up the new callback on the next tick, so no special
|
|
52
|
+
handling is needed.
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
ctrlAPI.update = function (newCbk) {
|
|
56
|
+
messageCbk = newCbk;
|
|
57
|
+
if (!is_tty) {
|
|
58
|
+
console.log(messageCbk(false));
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Client usage:**
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
var progress = spinner.create(function (interactive) {
|
|
67
|
+
if (phase === 'md5') return interactive ? 'Hashing...' : 'Hashing...';
|
|
68
|
+
return interactive ? 'Loading...' : 'Loading...';
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// When phase changes:
|
|
72
|
+
phase = 'loading';
|
|
73
|
+
progress.update(function (interactive) {
|
|
74
|
+
return 'Loading...';
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This is purely additive — no existing behaviour changes. The client is free
|
|
79
|
+
to ignore `update()` and just stop/recreate as before.
|
|
80
|
+
|
|
81
|
+
## Task 3: Add timestamp prefix to non-TTY output
|
|
82
|
+
|
|
83
|
+
**Location:** `index.js`, the `spinner.create()` function signature and all
|
|
84
|
+
`console.log` call sites in the non-TTY branches.
|
|
85
|
+
|
|
86
|
+
**Problem:** When reading log files from a non-interactive run, there's no way
|
|
87
|
+
to tell when each progress line was emitted. The client currently works around
|
|
88
|
+
this by embedding timestamps in the callback message itself, but this is
|
|
89
|
+
verbose, inconsistent, and forces every client to reimplement the same logic.
|
|
90
|
+
|
|
91
|
+
**Goal:** Add an optional `timestamp` parameter to `spinner.create` that, when
|
|
92
|
+
enabled, prepends an ISO-8601 timestamp to every non-TTY output line.
|
|
93
|
+
|
|
94
|
+
**API change:**
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
// Current — no timestamps (backward compatible):
|
|
98
|
+
spinner.create(cbk)
|
|
99
|
+
spinner.create(cbk, {})
|
|
100
|
+
|
|
101
|
+
// New — with timestamps:
|
|
102
|
+
spinner.create(cbk, { timestamp: 'utc' }) // [2026-06-05T13:03:40Z]
|
|
103
|
+
spinner.create(cbk, { timestamp: 'local' }) // [2026-06-05T15:03:40+02:00]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- When `opts.timestamp` is `true`, behave as `'utc'`.
|
|
107
|
+
- When falsy or absent, no timestamp is prepended.
|
|
108
|
+
- Any other string value is passed through to the `Intl.DateTimeFormat`
|
|
109
|
+
`timeZone` option (e.g. `'America/New_York'`), so it Just Works for any
|
|
110
|
+
IANA timezone.
|
|
111
|
+
|
|
112
|
+
**Implementation:**
|
|
113
|
+
|
|
114
|
+
1. Change the `create` signature from `create(messageCbk)` to
|
|
115
|
+
`create(messageCbk, opts)` where `opts` defaults to `{}`.
|
|
116
|
+
|
|
117
|
+
2. Compute a `ts` helper function (or inline it) at the top of the function
|
|
118
|
+
body:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
var tsEnabled = !!(opts && opts.timestamp);
|
|
122
|
+
var tsOpt = opts && opts.timestamp;
|
|
123
|
+
if (tsEnabled) {
|
|
124
|
+
// true → 'utc', already a string → use as-is, else 'utc'
|
|
125
|
+
if (tsOpt === true) tsOpt = 'utc';
|
|
126
|
+
if (typeof tsOpt !== 'string') tsOpt = 'utc';
|
|
127
|
+
}
|
|
128
|
+
function fmtTS() {
|
|
129
|
+
if (!tsEnabled) return '';
|
|
130
|
+
var now = new Date();
|
|
131
|
+
var s;
|
|
132
|
+
if (tsOpt === 'utc') {
|
|
133
|
+
s = now.toISOString(); // always Z
|
|
134
|
+
} else {
|
|
135
|
+
// local or arbitrary IANA timezone
|
|
136
|
+
var opt = { year: 'numeric', month: '2-digit', day: '2-digit',
|
|
137
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
138
|
+
timeZoneName: 'shortOffset', timeZone: tsOpt };
|
|
139
|
+
var parts = Intl.DateTimeFormat('en-CA', opt).formatToParts(now);
|
|
140
|
+
// format as YYYY-MM-DDTHH:mm:ss±HH:mm (ISO 8601)
|
|
141
|
+
// … assembly logic
|
|
142
|
+
}
|
|
143
|
+
return '[' + s + '] ';
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
3. Prepend `fmtTS()` to every non-TTY `console.log` call. There are three
|
|
148
|
+
such sites:
|
|
149
|
+
- The initial log line when the callback is first registered (around line 80):
|
|
150
|
+
`console.log(fmtTS() + '→ ' + messageCbk(false));`
|
|
151
|
+
- The `update()` path added in Task 2:
|
|
152
|
+
`console.log(fmtTS() + messageCbk(false));`
|
|
153
|
+
- The stop path (Task 1):
|
|
154
|
+
`console.log(fmtTS() + s + ' ' + messageCbk(true));`
|
|
155
|
+
|
|
156
|
+
(The `→` symbol is only used in the initial/update log lines, not in the
|
|
157
|
+
stop line, since the stop already has the ✓/✗ symbol.)
|
|
158
|
+
|
|
159
|
+
4. The interactive (TTY) path is **not** modified — timestamps would clutter
|
|
160
|
+
the animated spinner line.
|
|
161
|
+
|
|
162
|
+
**Client migration:**
|
|
163
|
+
|
|
164
|
+
Once this is published, the client removes its own timestamp formatting and
|
|
165
|
+
passes the option instead:
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
// Before (cmt_import.js):
|
|
169
|
+
var progress = spinner.create(function (interactive) {
|
|
170
|
+
if (phase === 'md5') return interactive
|
|
171
|
+
? "Calculating file hash (md5)..."
|
|
172
|
+
: "[" + new Date().toISOString() + "] Calculating file hash (md5)...";
|
|
173
|
+
// ...
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// After:
|
|
177
|
+
var progress = spinner.create(function (interactive) {
|
|
178
|
+
if (phase === 'md5') return "Calculating file hash (md5)...";
|
|
179
|
+
// ...
|
|
180
|
+
}, { timestamp: 'utc' });
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The `update()` method receives the same `opts` automatically (no need to
|
|
184
|
+
pass it again on every call).
|
|
185
|
+
|
|
186
|
+
**Edge cases:**
|
|
187
|
+
- `Intl.DateTimeFormat` throws on invalid timezone strings. Let it propagate
|
|
188
|
+
— the developer will fix their config.
|
|
189
|
+
- `en-CA` locale gives `YYYY-MM-DD` format naturally. If the browser/runtime
|
|
190
|
+
doesn't support `en-CA`, fall back to manual `padStart` construction.
|
|
191
|
+
(Node.js supports it since v13, so this is not a concern in practice.)
|