ytstats 0.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.
@@ -0,0 +1,123 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { configDir } from './paths.js';
5
+
6
+ export { configDir };
7
+
8
+ // Everything this store holds is a secret (OAuth client secret, refresh tokens),
9
+ // so files are 0600 and the directory is 0700. Windows ignores POSIX modes; there
10
+ // the per-user %APPDATA% location is the protection.
11
+ const FILE_MODE = 0o600;
12
+ const DIR_MODE = 0o700;
13
+
14
+ /**
15
+ * File names are always flat inside the config dir. Anything that could escape it
16
+ * (separators, traversal, absolute paths, NUL) is rejected rather than sanitised —
17
+ * a caller passing one of those has a bug, and silently rewriting it hides it.
18
+ */
19
+ function assertSafeName(name) {
20
+ if (
21
+ typeof name !== 'string' ||
22
+ name.length === 0 ||
23
+ name !== path.basename(name) ||
24
+ name === '.' ||
25
+ name === '..' ||
26
+ path.isAbsolute(name) ||
27
+ name.includes('\0')
28
+ ) {
29
+ throw new Error(`Invalid config file name: ${JSON.stringify(name)}`);
30
+ }
31
+ return name;
32
+ }
33
+
34
+ function ensureDir() {
35
+ const dir = configDir();
36
+ fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE });
37
+ // mkdir's mode is masked by umask, and the dir may predate us, so assert it.
38
+ try {
39
+ fs.chmodSync(dir, DIR_MODE);
40
+ } catch {
41
+ // Windows and exotic filesystems: not fatal.
42
+ }
43
+ return dir;
44
+ }
45
+
46
+ function filePath(name) {
47
+ return path.join(configDir(), assertSafeName(name));
48
+ }
49
+
50
+ /** Parsed JSON, or null when the file is absent or unreadable/corrupt. */
51
+ export function readJson(name) {
52
+ const p = filePath(name);
53
+ let raw;
54
+ try {
55
+ raw = fs.readFileSync(p, 'utf-8');
56
+ } catch {
57
+ return null;
58
+ }
59
+ try {
60
+ return JSON.parse(raw);
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Atomically write JSON with 0600 permissions.
68
+ *
69
+ * Written to a unique temp file in the same directory then renamed, so a crash or
70
+ * a concurrent reader never observes a half-written token file. The temp file is
71
+ * created with the final mode, so the secret is never briefly world-readable.
72
+ */
73
+ export function writeJson(name, value) {
74
+ const dir = ensureDir();
75
+ const target = path.join(dir, assertSafeName(name));
76
+ const tmp = path.join(dir, `.${name}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`);
77
+
78
+ try {
79
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: FILE_MODE });
80
+ try {
81
+ fs.chmodSync(tmp, FILE_MODE);
82
+ } catch {
83
+ // Windows: no-op.
84
+ }
85
+ fs.renameSync(tmp, target);
86
+ } catch (err) {
87
+ try {
88
+ fs.unlinkSync(tmp);
89
+ } catch {
90
+ // Best effort.
91
+ }
92
+ throw err;
93
+ }
94
+
95
+ // rename preserves the temp file's mode, but if the target pre-existed with looser
96
+ // permissions on some filesystems, make sure we end up locked down either way.
97
+ try {
98
+ fs.chmodSync(target, FILE_MODE);
99
+ } catch {
100
+ // Windows: no-op.
101
+ }
102
+ return target;
103
+ }
104
+
105
+ /** Delete a stored file. Returns true if something was removed. */
106
+ export function removeFile(name) {
107
+ const p = filePath(name);
108
+ try {
109
+ fs.unlinkSync(p);
110
+ return true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ /** Names of stored files (excludes in-flight temp files). */
117
+ export function listFiles() {
118
+ try {
119
+ return fs.readdirSync(configDir()).filter(f => !f.includes('.tmp-'));
120
+ } catch {
121
+ return [];
122
+ }
123
+ }
package/src/dates.js ADDED
@@ -0,0 +1,73 @@
1
+ import { YtStatsError, ERROR_CODES } from './errors.js';
2
+
3
+ const DEFAULT_DAYS = 90;
4
+ const MAX_DAYS = 3650;
5
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}$/;
6
+
7
+ /** UTC YYYY-MM-DD. All API date arguments are plain calendar dates, never times. */
8
+ export function toIsoDate(date) {
9
+ return date.toISOString().slice(0, 10);
10
+ }
11
+
12
+ function assertIsoDate(value, label) {
13
+ if (!ISO_RE.test(value)) {
14
+ throw new YtStatsError(`${label} must be in YYYY-MM-DD format, got "${value}".`, {
15
+ code: ERROR_CODES.INVALID_INPUT,
16
+ });
17
+ }
18
+ const parsed = new Date(`${value}T00:00:00Z`);
19
+ // Round-tripping catches calendar-invalid dates like 2026-02-31, which Date
20
+ // silently rolls forward into March.
21
+ if (Number.isNaN(parsed.getTime()) || toIsoDate(parsed) !== value) {
22
+ throw new YtStatsError(`${label} is not a valid date: "${value}".`, {
23
+ code: ERROR_CODES.INVALID_INPUT,
24
+ });
25
+ }
26
+ return parsed;
27
+ }
28
+
29
+ /**
30
+ * Resolve a reporting window.
31
+ *
32
+ * `--start`/`--end` win where given; otherwise the window is the last `days`
33
+ * ending today (UTC).
34
+ */
35
+ export function resolveDateRange({ days, start, end, now = new Date() } = {}) {
36
+ const endDate = end ? (assertIsoDate(end, 'End date'), end) : toIsoDate(now);
37
+
38
+ if (start) {
39
+ assertIsoDate(start, 'Start date');
40
+ if (start > endDate) {
41
+ throw new YtStatsError(`Start date (${start}) must be before end date (${endDate}).`, {
42
+ code: ERROR_CODES.INVALID_INPUT,
43
+ });
44
+ }
45
+ return { startDate: start, endDate };
46
+ }
47
+
48
+ const count = days === undefined ? DEFAULT_DAYS : Number(days);
49
+ if (!Number.isFinite(count)) {
50
+ throw new YtStatsError(`--days must be a number, got "${days}".`, {
51
+ code: ERROR_CODES.INVALID_INPUT,
52
+ });
53
+ }
54
+ if (count <= 0) {
55
+ throw new YtStatsError('--days must be a positive number.', { code: ERROR_CODES.INVALID_INPUT });
56
+ }
57
+ if (count > MAX_DAYS) {
58
+ throw new YtStatsError(`--days must be ${MAX_DAYS} or fewer.`, { code: ERROR_CODES.INVALID_INPUT });
59
+ }
60
+
61
+ const endMs = new Date(`${endDate}T00:00:00Z`).getTime();
62
+ return {
63
+ startDate: toIsoDate(new Date(endMs - count * 86400000)),
64
+ endDate,
65
+ };
66
+ }
67
+
68
+ /** Whole days between two ISO dates. */
69
+ export function daysBetween(startDate, endDate) {
70
+ const a = new Date(`${startDate}T00:00:00Z`).getTime();
71
+ const b = new Date(`${endDate}T00:00:00Z`).getTime();
72
+ return Math.round((b - a) / 86400000);
73
+ }