devgrowth 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/README.md +135 -111
- package/package.json +52 -48
- package/src/commands/config.js +54 -50
- package/src/commands/dashboard.js +13 -0
- package/src/commands/history.js +75 -73
- package/src/commands/log.js +40 -36
- package/src/commands/login.js +127 -0
- package/src/commands/logout.js +26 -0
- package/src/commands/milestone.js +54 -51
- package/src/commands/review.js +62 -57
- package/src/commands/start.js +97 -91
- package/src/commands/status.js +55 -53
- package/src/commands/sync.js +29 -0
- package/src/core/apiClient.js +46 -0
- package/src/core/auth.js +34 -0
- package/src/core/banner.js +293 -293
- package/src/core/configManager.js +57 -70
- package/src/core/eventLog.js +112 -0
- package/src/core/logger.js +62 -62
- package/src/core/milestones.js +62 -72
- package/src/core/prompt.js +58 -0
- package/src/core/rebuild.js +37 -0
- package/src/core/schedule.js +1 -70
- package/src/core/stats.js +43 -130
- package/src/core/storage.js +52 -33
- package/src/core/sync.js +101 -0
- package/src/index.js +221 -152
package/src/core/banner.js
CHANGED
|
@@ -1,293 +1,293 @@
|
|
|
1
|
-
import * as os from 'os';
|
|
2
|
-
import * as path from 'path';
|
|
3
|
-
import chalk from 'chalk';
|
|
4
|
-
import * as configManager from './configManager.js';
|
|
5
|
-
import * as schedule from './schedule.js';
|
|
6
|
-
import * as stats from './stats.js';
|
|
7
|
-
import * as storage from './storage.js';
|
|
8
|
-
|
|
9
|
-
export const VERSION = '1.
|
|
10
|
-
export const TAGLINE = 'Zero-friction developer growth';
|
|
11
|
-
|
|
12
|
-
// Each glyph is an 8-row pixel grid, 5 columns wide. Capitals and ascenders
|
|
13
|
-
// use all 8 rows; lowercase letters occupy the x-height rows (3-7).
|
|
14
|
-
const GLYPHS = {
|
|
15
|
-
d: ['....#', '....#', '....#', '.####', '#...#', '#...#', '#...#', '.####'],
|
|
16
|
-
e: ['.....', '.....', '.....', '.###.', '#...#', '#####', '#....', '.###.'],
|
|
17
|
-
v: ['.....', '.....', '.....', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
|
|
18
|
-
G: ['.###.', '#...#', '#....', '#....', '#.###', '#...#', '#...#', '.####'],
|
|
19
|
-
r: ['.....', '.....', '.....', '#.##.', '##...', '#....', '#....', '#....'],
|
|
20
|
-
o: ['.....', '.....', '.....', '.###.', '#...#', '#...#', '#...#', '.###.'],
|
|
21
|
-
w: ['.....', '.....', '.....', '#...#', '#...#', '#.#.#', '##.##', '#...#'],
|
|
22
|
-
t: ['.....', '..#..', '..#..', '.###.', '..#..', '..#..', '..#..', '..###'],
|
|
23
|
-
h: ['#....', '#....', '#....', '#.##.', '##..#', '#...#', '#...#', '#...#']
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const GLYPH_WIDTH = 5;
|
|
27
|
-
const GLYPH_GAP = 1;
|
|
28
|
-
const NAME = 'devGrowth';
|
|
29
|
-
|
|
30
|
-
// Half blocks pair two pixel rows into one text row, so 8 pixel rows render as
|
|
31
|
-
// 4 lines. Only ▀ ▄ █ are used, which the legacy Windows console also has.
|
|
32
|
-
function halfBlock(top, bottom) {
|
|
33
|
-
if (top === '#' && bottom === '#') return '█';
|
|
34
|
-
if (top === '#') return '▀';
|
|
35
|
-
if (bottom === '#') return '▄';
|
|
36
|
-
return ' ';
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function renderWordmark(word = NAME) {
|
|
40
|
-
const rows = [];
|
|
41
|
-
for (let pair = 0; pair < 4; pair++) {
|
|
42
|
-
let line = '';
|
|
43
|
-
for (const ch of word) {
|
|
44
|
-
const glyph = GLYPHS[ch];
|
|
45
|
-
if (!glyph) continue;
|
|
46
|
-
for (let col = 0; col < GLYPH_WIDTH; col++) {
|
|
47
|
-
line += halfBlock(glyph[pair * 2][col], glyph[pair * 2 + 1][col]);
|
|
48
|
-
}
|
|
49
|
-
line += ' '.repeat(GLYPH_GAP);
|
|
50
|
-
}
|
|
51
|
-
rows.push(line.slice(0, line.length - GLYPH_GAP));
|
|
52
|
-
}
|
|
53
|
-
return rows;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export const WORDMARK = renderWordmark();
|
|
57
|
-
export const WORDMARK_WIDTH = WORDMARK[0].length;
|
|
58
|
-
|
|
59
|
-
// "dev" is three glyphs wide; the rest is "Growth". Splitting here lets the
|
|
60
|
-
// camelCase read as two tones the way the written name does.
|
|
61
|
-
const DEV_SPLIT = 3 * (GLYPH_WIDTH + GLYPH_GAP);
|
|
62
|
-
|
|
63
|
-
function colorWordmark() {
|
|
64
|
-
return WORDMARK.map(line =>
|
|
65
|
-
chalk.gray(line.slice(0, DEV_SPLIT)) + chalk.green(line.slice(DEV_SPLIT))
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Stored verbatim — the fullwidth characters (_ / Y ソ #) and the ideographic
|
|
70
|
-
// spaces (U+3000) are load-bearing alignment, not padding. Only the leading
|
|
71
|
-
// newline is stripped: String#trimStart would also eat the U+3000 spaces that
|
|
72
|
-
// indent the first row, shearing the shell off the body.
|
|
73
|
-
export const TURTLE_ART = `
|
|
74
|
-
__ _
|
|
75
|
-
/ ♯ # \/・_)
|
|
76
|
-
(ソ♯ # ♯ #Y/
|
|
77
|
-
(_)――-(_)′`.replace(/^\r?\n/, '');
|
|
78
|
-
|
|
79
|
-
export const TURTLE_ROWS = TURTLE_ART.split('\n');
|
|
80
|
-
|
|
81
|
-
// Gap between the wordmark and the turtle riding beside it.
|
|
82
|
-
const MASCOT_GAP = 2;
|
|
83
|
-
|
|
84
|
-
// Measures printable width, ignoring the ANSI escapes chalk injects.
|
|
85
|
-
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
86
|
-
export function visibleLength(str) {
|
|
87
|
-
return str.replace(ANSI, '').length;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Ranges the Unicode East Asian Width table marks Wide or Fullwidth, which a
|
|
91
|
-
// terminal draws in two cells. Covers what the turtle uses: U+3000 (ideographic
|
|
92
|
-
// space) and the fullwidth forms _ / # \ ( Y at U+FF01–FF60. Deliberately
|
|
93
|
-
// excludes U+FF61–FFDC — the halfwidth katakana ソ and ・ occupy one cell.
|
|
94
|
-
const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-!-⦆¢-₩]/;
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Terminal columns a string occupies. `visibleLength` counts code points, which
|
|
98
|
-
* under-reports any fullwidth character — use this wherever the answer feeds a
|
|
99
|
-
* layout decision.
|
|
100
|
-
*/
|
|
101
|
-
export function displayWidth(str) {
|
|
102
|
-
let cells = 0;
|
|
103
|
-
for (const ch of str.replace(ANSI, '')) {
|
|
104
|
-
cells += WIDE.test(ch) ? 2 : 1;
|
|
105
|
-
}
|
|
106
|
-
return cells;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function terminalWidth(stream = process.stdout) {
|
|
110
|
-
return stream && stream.columns ? stream.columns : 80;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function tildify(absPath) {
|
|
114
|
-
const home = os.homedir();
|
|
115
|
-
const short = absPath.startsWith(home) ? `~${absPath.slice(home.length)}` : absPath;
|
|
116
|
-
// Display with forward slashes to match the paths the other commands print.
|
|
117
|
-
return short.split(path.sep).join('/');
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function plural(count, word) {
|
|
121
|
-
return `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Caps a raw (uncolored) value so one long resource name cannot blow out the
|
|
125
|
-
// panel width. Must run before chalk wraps the string in escape codes.
|
|
126
|
-
export function clip(str, max) {
|
|
127
|
-
const value = String(str);
|
|
128
|
-
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function renderBar(completed, total, width = 5) {
|
|
132
|
-
if (!total || total <= 0) return '░'.repeat(width);
|
|
133
|
-
const ratio = Math.max(0, Math.min(1, completed / total));
|
|
134
|
-
const filled = Math.round(ratio * width);
|
|
135
|
-
return '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Decides whether the automatic banner should be drawn. Suppressed when output
|
|
140
|
-
* is piped or redirected so `devgrowth ... | jq` and cron-style runs stay clean.
|
|
141
|
-
*/
|
|
142
|
-
export function shouldShowBanner(env = process.env, stream = process.stdout) {
|
|
143
|
-
if (env.DEVGROWTH_NO_BANNER) return false;
|
|
144
|
-
if (env.DEVGROWTH_BANNER) return true;
|
|
145
|
-
return Boolean(stream && stream.isTTY);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Reads config/stats for the info panel. Never throws — a missing config just
|
|
150
|
-
* means DevGrowth has not been initialized yet, which the panel reports.
|
|
151
|
-
*/
|
|
152
|
-
export async function gatherContext(now = new Date()) {
|
|
153
|
-
const ctx = {
|
|
154
|
-
version: VERSION,
|
|
155
|
-
user: os.userInfo().username,
|
|
156
|
-
host: clip(os.hostname(), 24),
|
|
157
|
-
node: process.version,
|
|
158
|
-
dataDir: clip(tildify(storage.getDevGrowthDir()), 32),
|
|
159
|
-
initialized: false
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
let config;
|
|
163
|
-
try {
|
|
164
|
-
config = await configManager.getConfig();
|
|
165
|
-
} catch {
|
|
166
|
-
return ctx;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
ctx.initialized = true;
|
|
170
|
-
ctx.user = config.user || ctx.user;
|
|
171
|
-
|
|
172
|
-
try {
|
|
173
|
-
const today = schedule.getTodaySkill(config, now);
|
|
174
|
-
ctx.skill = today.skill;
|
|
175
|
-
ctx.focus = today.focus;
|
|
176
|
-
ctx.resource = today.resource;
|
|
177
|
-
ctx.startTime = config.session?.startTime;
|
|
178
|
-
ctx.durationMinutes = config.session?.durationMinutes;
|
|
179
|
-
} catch { /* a hand-edited schedule should not break the banner */ }
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
const s = await stats.getStats();
|
|
183
|
-
const weekProgress = stats.getCurrentWeekProgress(s, now);
|
|
184
|
-
ctx.weekNumber = schedule.getWeekNumber(schedule.getSessionDate(now));
|
|
185
|
-
ctx.weekCompleted = weekProgress.completed;
|
|
186
|
-
ctx.weekTotal = weekProgress.total;
|
|
187
|
-
ctx.currentStreakWeeks = s.currentStreakWeeks || 0;
|
|
188
|
-
ctx.longestStreakWeeks = s.longestStreakWeeks || 0;
|
|
189
|
-
ctx.totalSessions = s.totalSessions || 0;
|
|
190
|
-
ctx.totalMinutes = s.totalMinutesDeepWork || 0;
|
|
191
|
-
} catch { /* unreadable stats.json should not break the banner */ }
|
|
192
|
-
|
|
193
|
-
return ctx;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export function buildInfoRows(ctx) {
|
|
197
|
-
const rows = [];
|
|
198
|
-
|
|
199
|
-
if (!ctx.initialized) {
|
|
200
|
-
rows.push(['Status', chalk.yellow('not initialized')]);
|
|
201
|
-
rows.push(['Setup', `run ${chalk.bold('devgrowth init')}`]);
|
|
202
|
-
rows.push(['Data', chalk.gray(ctx.dataDir)]);
|
|
203
|
-
return rows;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (ctx.skill === 'rest') {
|
|
207
|
-
rows.push(['Tonight', `${chalk.blue('rest day')} — system still alive`]);
|
|
208
|
-
} else if (ctx.skill === 'review') {
|
|
209
|
-
rows.push(['Tonight', `${chalk.blue('weekly review')} — ${ctx.focus || ''}`.trim()]);
|
|
210
|
-
} else if (ctx.skill) {
|
|
211
|
-
const focus = ctx.focus ? ` ${chalk.gray('—')} ${ctx.focus}` : '';
|
|
212
|
-
rows.push(['Tonight', `${chalk.bold.cyan(ctx.skill)}${focus}`]);
|
|
213
|
-
if (ctx.resource) rows.push(['Resource', clip(ctx.resource, 32)]);
|
|
214
|
-
if (ctx.startTime) {
|
|
215
|
-
rows.push(['Session', `${ctx.startTime} · ${ctx.durationMinutes ?? 20} min`]);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (ctx.weekCompleted !== undefined) {
|
|
220
|
-
const bar = renderBar(ctx.weekCompleted, ctx.weekTotal);
|
|
221
|
-
rows.push([
|
|
222
|
-
`Week ${ctx.weekNumber}`,
|
|
223
|
-
`${chalk.green(bar)} ${ctx.weekCompleted}/${ctx.weekTotal} sessions`
|
|
224
|
-
]);
|
|
225
|
-
rows.push([
|
|
226
|
-
'Streak',
|
|
227
|
-
`${chalk.bold(plural(ctx.currentStreakWeeks, 'week'))} ${chalk.gray(`(best: ${ctx.longestStreakWeeks})`)}`
|
|
228
|
-
]);
|
|
229
|
-
rows.push([
|
|
230
|
-
'Totals',
|
|
231
|
-
chalk.gray(`${plural(ctx.totalSessions, 'session')} · ${ctx.totalMinutes} min`)
|
|
232
|
-
]);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
rows.push(['Data', chalk.gray(ctx.dataDir)]);
|
|
236
|
-
return rows;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function formatRows(rows) {
|
|
240
|
-
const labelWidth = rows.reduce((max, [label]) => Math.max(max, label.length), 0);
|
|
241
|
-
// Pad the raw label before colorizing — ANSI codes count toward String#padEnd.
|
|
242
|
-
return rows.map(([label, value]) => `${chalk.bold.green(label.padEnd(labelWidth))} ${value}`);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export function renderCompact(ctx) {
|
|
246
|
-
const name = `${chalk.gray('dev')}${chalk.bold.green('Growth')}`;
|
|
247
|
-
const trailer = ctx.initialized && ctx.skill && ctx.skill !== 'rest' && ctx.skill !== 'review'
|
|
248
|
-
? `${chalk.cyan(ctx.skill)}${ctx.focus ? chalk.gray(` · ${ctx.focus}`) : ''}`
|
|
249
|
-
: chalk.gray(TAGLINE);
|
|
250
|
-
return `${name} ${chalk.gray(`v${ctx.version}`)} ${chalk.gray('—')} ${trailer}`;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* The wordmark with the turtle riding beside it. Both are four rows tall, so
|
|
255
|
-
* the two blocks zip line-for-line. Every wordmark row is exactly
|
|
256
|
-
* WORDMARK_WIDTH, so no padding is needed to keep the turtle's left edge flush.
|
|
257
|
-
*/
|
|
258
|
-
export function renderHeader() {
|
|
259
|
-
const wordmark = colorWordmark();
|
|
260
|
-
const gap = ' '.repeat(MASCOT_GAP);
|
|
261
|
-
return wordmark.map((line, i) => {
|
|
262
|
-
const shell = TURTLE_ROWS[i];
|
|
263
|
-
return shell === undefined ? line : `${line}${gap}${chalk.green(shell)}`;
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
export const HEADER_WIDTH =
|
|
268
|
-
WORDMARK_WIDTH + MASCOT_GAP + Math.max(...TURTLE_ROWS.map(displayWidth));
|
|
269
|
-
|
|
270
|
-
export function renderBanner(ctx, options = {}) {
|
|
271
|
-
const width = options.width ?? terminalWidth();
|
|
272
|
-
const identity = ctx.user
|
|
273
|
-
? `${chalk.bold.green(ctx.user)}${chalk.gray('@')}${chalk.bold.green(ctx.host)} ${chalk.gray(`· v${ctx.version}`)}`
|
|
274
|
-
: chalk.gray(`v${ctx.version}`);
|
|
275
|
-
const body = formatRows(buildInfoRows(ctx));
|
|
276
|
-
|
|
277
|
-
// Wordmark plus turtle sets the minimum width; below it the header would wrap.
|
|
278
|
-
const widest = Math.max(HEADER_WIDTH, ...[identity, ...body].map(visibleLength));
|
|
279
|
-
if (options.compact || width < widest) return renderCompact(ctx);
|
|
280
|
-
|
|
281
|
-
const rule = chalk.gray('─'.repeat(widest));
|
|
282
|
-
return [...renderHeader(), '', identity, rule, ...body].join('\n');
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Draws the banner. `force` bypasses the TTY check (used by `devgrowth banner`).
|
|
287
|
-
*/
|
|
288
|
-
export async function printBanner(options = {}) {
|
|
289
|
-
if (!options.force && !shouldShowBanner()) return false;
|
|
290
|
-
const ctx = options.context ?? await gatherContext();
|
|
291
|
-
console.log(`\n${renderBanner(ctx, options)}\n`);
|
|
292
|
-
return true;
|
|
293
|
-
}
|
|
1
|
+
import * as os from 'os';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import * as configManager from './configManager.js';
|
|
5
|
+
import * as schedule from './schedule.js';
|
|
6
|
+
import * as stats from './stats.js';
|
|
7
|
+
import * as storage from './storage.js';
|
|
8
|
+
|
|
9
|
+
export const VERSION = '1.1.0';
|
|
10
|
+
export const TAGLINE = 'Zero-friction developer growth';
|
|
11
|
+
|
|
12
|
+
// Each glyph is an 8-row pixel grid, 5 columns wide. Capitals and ascenders
|
|
13
|
+
// use all 8 rows; lowercase letters occupy the x-height rows (3-7).
|
|
14
|
+
const GLYPHS = {
|
|
15
|
+
d: ['....#', '....#', '....#', '.####', '#...#', '#...#', '#...#', '.####'],
|
|
16
|
+
e: ['.....', '.....', '.....', '.###.', '#...#', '#####', '#....', '.###.'],
|
|
17
|
+
v: ['.....', '.....', '.....', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
|
|
18
|
+
G: ['.###.', '#...#', '#....', '#....', '#.###', '#...#', '#...#', '.####'],
|
|
19
|
+
r: ['.....', '.....', '.....', '#.##.', '##...', '#....', '#....', '#....'],
|
|
20
|
+
o: ['.....', '.....', '.....', '.###.', '#...#', '#...#', '#...#', '.###.'],
|
|
21
|
+
w: ['.....', '.....', '.....', '#...#', '#...#', '#.#.#', '##.##', '#...#'],
|
|
22
|
+
t: ['.....', '..#..', '..#..', '.###.', '..#..', '..#..', '..#..', '..###'],
|
|
23
|
+
h: ['#....', '#....', '#....', '#.##.', '##..#', '#...#', '#...#', '#...#']
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const GLYPH_WIDTH = 5;
|
|
27
|
+
const GLYPH_GAP = 1;
|
|
28
|
+
const NAME = 'devGrowth';
|
|
29
|
+
|
|
30
|
+
// Half blocks pair two pixel rows into one text row, so 8 pixel rows render as
|
|
31
|
+
// 4 lines. Only ▀ ▄ █ are used, which the legacy Windows console also has.
|
|
32
|
+
function halfBlock(top, bottom) {
|
|
33
|
+
if (top === '#' && bottom === '#') return '█';
|
|
34
|
+
if (top === '#') return '▀';
|
|
35
|
+
if (bottom === '#') return '▄';
|
|
36
|
+
return ' ';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function renderWordmark(word = NAME) {
|
|
40
|
+
const rows = [];
|
|
41
|
+
for (let pair = 0; pair < 4; pair++) {
|
|
42
|
+
let line = '';
|
|
43
|
+
for (const ch of word) {
|
|
44
|
+
const glyph = GLYPHS[ch];
|
|
45
|
+
if (!glyph) continue;
|
|
46
|
+
for (let col = 0; col < GLYPH_WIDTH; col++) {
|
|
47
|
+
line += halfBlock(glyph[pair * 2][col], glyph[pair * 2 + 1][col]);
|
|
48
|
+
}
|
|
49
|
+
line += ' '.repeat(GLYPH_GAP);
|
|
50
|
+
}
|
|
51
|
+
rows.push(line.slice(0, line.length - GLYPH_GAP));
|
|
52
|
+
}
|
|
53
|
+
return rows;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const WORDMARK = renderWordmark();
|
|
57
|
+
export const WORDMARK_WIDTH = WORDMARK[0].length;
|
|
58
|
+
|
|
59
|
+
// "dev" is three glyphs wide; the rest is "Growth". Splitting here lets the
|
|
60
|
+
// camelCase read as two tones the way the written name does.
|
|
61
|
+
const DEV_SPLIT = 3 * (GLYPH_WIDTH + GLYPH_GAP);
|
|
62
|
+
|
|
63
|
+
function colorWordmark() {
|
|
64
|
+
return WORDMARK.map(line =>
|
|
65
|
+
chalk.gray(line.slice(0, DEV_SPLIT)) + chalk.green(line.slice(DEV_SPLIT))
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Stored verbatim — the fullwidth characters (_ / Y ソ #) and the ideographic
|
|
70
|
+
// spaces (U+3000) are load-bearing alignment, not padding. Only the leading
|
|
71
|
+
// newline is stripped: String#trimStart would also eat the U+3000 spaces that
|
|
72
|
+
// indent the first row, shearing the shell off the body.
|
|
73
|
+
export const TURTLE_ART = `
|
|
74
|
+
__ _
|
|
75
|
+
/ ♯ # \/・_)
|
|
76
|
+
(ソ♯ # ♯ #Y/
|
|
77
|
+
(_)――-(_)′`.replace(/^\r?\n/, '');
|
|
78
|
+
|
|
79
|
+
export const TURTLE_ROWS = TURTLE_ART.split('\n');
|
|
80
|
+
|
|
81
|
+
// Gap between the wordmark and the turtle riding beside it.
|
|
82
|
+
const MASCOT_GAP = 2;
|
|
83
|
+
|
|
84
|
+
// Measures printable width, ignoring the ANSI escapes chalk injects.
|
|
85
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
86
|
+
export function visibleLength(str) {
|
|
87
|
+
return str.replace(ANSI, '').length;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Ranges the Unicode East Asian Width table marks Wide or Fullwidth, which a
|
|
91
|
+
// terminal draws in two cells. Covers what the turtle uses: U+3000 (ideographic
|
|
92
|
+
// space) and the fullwidth forms _ / # \ ( Y at U+FF01–FF60. Deliberately
|
|
93
|
+
// excludes U+FF61–FFDC — the halfwidth katakana ソ and ・ occupy one cell.
|
|
94
|
+
const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-!-⦆¢-₩]/;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Terminal columns a string occupies. `visibleLength` counts code points, which
|
|
98
|
+
* under-reports any fullwidth character — use this wherever the answer feeds a
|
|
99
|
+
* layout decision.
|
|
100
|
+
*/
|
|
101
|
+
export function displayWidth(str) {
|
|
102
|
+
let cells = 0;
|
|
103
|
+
for (const ch of str.replace(ANSI, '')) {
|
|
104
|
+
cells += WIDE.test(ch) ? 2 : 1;
|
|
105
|
+
}
|
|
106
|
+
return cells;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function terminalWidth(stream = process.stdout) {
|
|
110
|
+
return stream && stream.columns ? stream.columns : 80;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function tildify(absPath) {
|
|
114
|
+
const home = os.homedir();
|
|
115
|
+
const short = absPath.startsWith(home) ? `~${absPath.slice(home.length)}` : absPath;
|
|
116
|
+
// Display with forward slashes to match the paths the other commands print.
|
|
117
|
+
return short.split(path.sep).join('/');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function plural(count, word) {
|
|
121
|
+
return `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Caps a raw (uncolored) value so one long resource name cannot blow out the
|
|
125
|
+
// panel width. Must run before chalk wraps the string in escape codes.
|
|
126
|
+
export function clip(str, max) {
|
|
127
|
+
const value = String(str);
|
|
128
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function renderBar(completed, total, width = 5) {
|
|
132
|
+
if (!total || total <= 0) return '░'.repeat(width);
|
|
133
|
+
const ratio = Math.max(0, Math.min(1, completed / total));
|
|
134
|
+
const filled = Math.round(ratio * width);
|
|
135
|
+
return '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Decides whether the automatic banner should be drawn. Suppressed when output
|
|
140
|
+
* is piped or redirected so `devgrowth ... | jq` and cron-style runs stay clean.
|
|
141
|
+
*/
|
|
142
|
+
export function shouldShowBanner(env = process.env, stream = process.stdout) {
|
|
143
|
+
if (env.DEVGROWTH_NO_BANNER) return false;
|
|
144
|
+
if (env.DEVGROWTH_BANNER) return true;
|
|
145
|
+
return Boolean(stream && stream.isTTY);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reads config/stats for the info panel. Never throws — a missing config just
|
|
150
|
+
* means DevGrowth has not been initialized yet, which the panel reports.
|
|
151
|
+
*/
|
|
152
|
+
export async function gatherContext(now = new Date()) {
|
|
153
|
+
const ctx = {
|
|
154
|
+
version: VERSION,
|
|
155
|
+
user: os.userInfo().username,
|
|
156
|
+
host: clip(os.hostname(), 24),
|
|
157
|
+
node: process.version,
|
|
158
|
+
dataDir: clip(tildify(storage.getDevGrowthDir()), 32),
|
|
159
|
+
initialized: false
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
let config;
|
|
163
|
+
try {
|
|
164
|
+
config = await configManager.getConfig();
|
|
165
|
+
} catch {
|
|
166
|
+
return ctx;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
ctx.initialized = true;
|
|
170
|
+
ctx.user = config.user || ctx.user;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const today = schedule.getTodaySkill(config, now);
|
|
174
|
+
ctx.skill = today.skill;
|
|
175
|
+
ctx.focus = today.focus;
|
|
176
|
+
ctx.resource = today.resource;
|
|
177
|
+
ctx.startTime = config.session?.startTime;
|
|
178
|
+
ctx.durationMinutes = config.session?.durationMinutes;
|
|
179
|
+
} catch { /* a hand-edited schedule should not break the banner */ }
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const s = await stats.getStats();
|
|
183
|
+
const weekProgress = stats.getCurrentWeekProgress(s, now);
|
|
184
|
+
ctx.weekNumber = schedule.getWeekNumber(schedule.getSessionDate(now));
|
|
185
|
+
ctx.weekCompleted = weekProgress.completed;
|
|
186
|
+
ctx.weekTotal = weekProgress.total;
|
|
187
|
+
ctx.currentStreakWeeks = s.currentStreakWeeks || 0;
|
|
188
|
+
ctx.longestStreakWeeks = s.longestStreakWeeks || 0;
|
|
189
|
+
ctx.totalSessions = s.totalSessions || 0;
|
|
190
|
+
ctx.totalMinutes = s.totalMinutesDeepWork || 0;
|
|
191
|
+
} catch { /* unreadable stats.json should not break the banner */ }
|
|
192
|
+
|
|
193
|
+
return ctx;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function buildInfoRows(ctx) {
|
|
197
|
+
const rows = [];
|
|
198
|
+
|
|
199
|
+
if (!ctx.initialized) {
|
|
200
|
+
rows.push(['Status', chalk.yellow('not initialized')]);
|
|
201
|
+
rows.push(['Setup', `run ${chalk.bold('devgrowth init')}`]);
|
|
202
|
+
rows.push(['Data', chalk.gray(ctx.dataDir)]);
|
|
203
|
+
return rows;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (ctx.skill === 'rest') {
|
|
207
|
+
rows.push(['Tonight', `${chalk.blue('rest day')} — system still alive`]);
|
|
208
|
+
} else if (ctx.skill === 'review') {
|
|
209
|
+
rows.push(['Tonight', `${chalk.blue('weekly review')} — ${ctx.focus || ''}`.trim()]);
|
|
210
|
+
} else if (ctx.skill) {
|
|
211
|
+
const focus = ctx.focus ? ` ${chalk.gray('—')} ${ctx.focus}` : '';
|
|
212
|
+
rows.push(['Tonight', `${chalk.bold.cyan(ctx.skill)}${focus}`]);
|
|
213
|
+
if (ctx.resource) rows.push(['Resource', clip(ctx.resource, 32)]);
|
|
214
|
+
if (ctx.startTime) {
|
|
215
|
+
rows.push(['Session', `${ctx.startTime} · ${ctx.durationMinutes ?? 20} min`]);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (ctx.weekCompleted !== undefined) {
|
|
220
|
+
const bar = renderBar(ctx.weekCompleted, ctx.weekTotal);
|
|
221
|
+
rows.push([
|
|
222
|
+
`Week ${ctx.weekNumber}`,
|
|
223
|
+
`${chalk.green(bar)} ${ctx.weekCompleted}/${ctx.weekTotal} sessions`
|
|
224
|
+
]);
|
|
225
|
+
rows.push([
|
|
226
|
+
'Streak',
|
|
227
|
+
`${chalk.bold(plural(ctx.currentStreakWeeks, 'week'))} ${chalk.gray(`(best: ${ctx.longestStreakWeeks})`)}`
|
|
228
|
+
]);
|
|
229
|
+
rows.push([
|
|
230
|
+
'Totals',
|
|
231
|
+
chalk.gray(`${plural(ctx.totalSessions, 'session')} · ${ctx.totalMinutes} min`)
|
|
232
|
+
]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
rows.push(['Data', chalk.gray(ctx.dataDir)]);
|
|
236
|
+
return rows;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function formatRows(rows) {
|
|
240
|
+
const labelWidth = rows.reduce((max, [label]) => Math.max(max, label.length), 0);
|
|
241
|
+
// Pad the raw label before colorizing — ANSI codes count toward String#padEnd.
|
|
242
|
+
return rows.map(([label, value]) => `${chalk.bold.green(label.padEnd(labelWidth))} ${value}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function renderCompact(ctx) {
|
|
246
|
+
const name = `${chalk.gray('dev')}${chalk.bold.green('Growth')}`;
|
|
247
|
+
const trailer = ctx.initialized && ctx.skill && ctx.skill !== 'rest' && ctx.skill !== 'review'
|
|
248
|
+
? `${chalk.cyan(ctx.skill)}${ctx.focus ? chalk.gray(` · ${ctx.focus}`) : ''}`
|
|
249
|
+
: chalk.gray(TAGLINE);
|
|
250
|
+
return `${name} ${chalk.gray(`v${ctx.version}`)} ${chalk.gray('—')} ${trailer}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The wordmark with the turtle riding beside it. Both are four rows tall, so
|
|
255
|
+
* the two blocks zip line-for-line. Every wordmark row is exactly
|
|
256
|
+
* WORDMARK_WIDTH, so no padding is needed to keep the turtle's left edge flush.
|
|
257
|
+
*/
|
|
258
|
+
export function renderHeader() {
|
|
259
|
+
const wordmark = colorWordmark();
|
|
260
|
+
const gap = ' '.repeat(MASCOT_GAP);
|
|
261
|
+
return wordmark.map((line, i) => {
|
|
262
|
+
const shell = TURTLE_ROWS[i];
|
|
263
|
+
return shell === undefined ? line : `${line}${gap}${chalk.green(shell)}`;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export const HEADER_WIDTH =
|
|
268
|
+
WORDMARK_WIDTH + MASCOT_GAP + Math.max(...TURTLE_ROWS.map(displayWidth));
|
|
269
|
+
|
|
270
|
+
export function renderBanner(ctx, options = {}) {
|
|
271
|
+
const width = options.width ?? terminalWidth();
|
|
272
|
+
const identity = ctx.user
|
|
273
|
+
? `${chalk.bold.green(ctx.user)}${chalk.gray('@')}${chalk.bold.green(ctx.host)} ${chalk.gray(`· v${ctx.version}`)}`
|
|
274
|
+
: chalk.gray(`v${ctx.version}`);
|
|
275
|
+
const body = formatRows(buildInfoRows(ctx));
|
|
276
|
+
|
|
277
|
+
// Wordmark plus turtle sets the minimum width; below it the header would wrap.
|
|
278
|
+
const widest = Math.max(HEADER_WIDTH, ...[identity, ...body].map(visibleLength));
|
|
279
|
+
if (options.compact || width < widest) return renderCompact(ctx);
|
|
280
|
+
|
|
281
|
+
const rule = chalk.gray('─'.repeat(widest));
|
|
282
|
+
return [...renderHeader(), '', identity, rule, ...body].join('\n');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Draws the banner. `force` bypasses the TTY check (used by `devgrowth banner`).
|
|
287
|
+
*/
|
|
288
|
+
export async function printBanner(options = {}) {
|
|
289
|
+
if (!options.force && !shouldShowBanner()) return false;
|
|
290
|
+
const ctx = options.context ?? await gatherContext();
|
|
291
|
+
console.log(`\n${renderBanner(ctx, options)}\n`);
|
|
292
|
+
return true;
|
|
293
|
+
}
|