@flowapt/flowiq-cli 0.6.8 → 0.7.1
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 +179 -1
- package/TEAM-GUIDE.md +28 -0
- package/package.json +1 -1
- package/src/commands/doctor.js +150 -0
- package/src/commands/insights.js +201 -0
- package/src/commands/org-flags.js +151 -0
- package/src/commands/team-updates.js +169 -0
- package/src/flag-values.js +42 -0
- package/src/flag-values.test.mjs +51 -0
- package/src/http.js +38 -0
- package/src/index.js +122 -1
- package/src/insights-config.js +69 -0
- package/src/insights-config.test.mjs +51 -0
- package/src/update-check.js +37 -3
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Pure helpers for `flowiq insights` — turning flags a human types
|
|
2
|
+
// (`--weekly monday`, `--monthly last`) into the `schedules[]` entries the
|
|
3
|
+
// export-insights scheduler reads, and describing a config the way the
|
|
4
|
+
// scheduler will actually run it. No I/O; unit-tested in insights-config.test.mjs.
|
|
5
|
+
|
|
6
|
+
export const DOW = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
|
7
|
+
export const DOW_LABEL = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
8
|
+
|
|
9
|
+
/** "monday" | "mon" | "1" → 1. Bare `--weekly` (true) → Monday, the scheduler's default. */
|
|
10
|
+
export function parseWeekday(v) {
|
|
11
|
+
if (v === true || v === undefined || v === null || v === "") return 1;
|
|
12
|
+
const s = String(v).trim().toLowerCase();
|
|
13
|
+
if (/^[0-6]$/.test(s)) return Number(s);
|
|
14
|
+
const idx = DOW.findIndex((d) => d === s || d.slice(0, 3) === s);
|
|
15
|
+
if (idx >= 0) return idx;
|
|
16
|
+
throw new Error(`--weekly expects a day (monday … sunday, or 0-6), got "${v}"`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** "last" | "15" → "last" | 15. Bare `--monthly` (true) → "last", the scheduler's default. */
|
|
20
|
+
export function parseMonthlyDay(v) {
|
|
21
|
+
if (v === true || v === undefined || v === null || v === "") return "last";
|
|
22
|
+
const s = String(v).trim().toLowerCase();
|
|
23
|
+
if (s === "last") return "last";
|
|
24
|
+
if (/^\d{1,2}$/.test(s)) {
|
|
25
|
+
const n = Number(s);
|
|
26
|
+
if (n >= 1 && n <= 28) return n;
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`--monthly expects a day of the month (1-28) or "last", got "${v}"`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build schedules[] from the enable flags. Returns null when no cadence flag
|
|
33
|
+
* was given (the server then keeps what exists, or applies its default).
|
|
34
|
+
* opts: { daily?, weekly?, monthly?, report?, storeOnly? }
|
|
35
|
+
*/
|
|
36
|
+
export function schedulesFromOpts(opts = {}) {
|
|
37
|
+
const report = opts.report ?? "standard";
|
|
38
|
+
if (!["standard", "advanced"].includes(report)) throw new Error(`--report must be standard or advanced, got "${report}"`);
|
|
39
|
+
const storeOnly = opts.storeOnly === true;
|
|
40
|
+
const out = [];
|
|
41
|
+
if (opts.daily) out.push({ frequency: "daily", report_type: report, store_only: storeOnly });
|
|
42
|
+
if (opts.weekly !== undefined) out.push({ frequency: "weekly", weekly_day: parseWeekday(opts.weekly), report_type: report, store_only: storeOnly });
|
|
43
|
+
if (opts.monthly !== undefined) out.push({ frequency: "monthly", monthly_day: parseMonthlyDay(opts.monthly), report_type: report, store_only: storeOnly });
|
|
44
|
+
return out.length ? out : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One line per schedule, in the scheduler's own vocabulary. */
|
|
48
|
+
export function describeSchedule(s) {
|
|
49
|
+
const tag = `${s.report_type ?? "standard"}${s.store_only ? "/store-only" : ""}`;
|
|
50
|
+
if (s.frequency === "daily") return `daily [${tag}]`;
|
|
51
|
+
if (s.frequency === "weekly") return `weekly ${DOW_LABEL[typeof s.weekly_day === "number" ? s.weekly_day : 1]} [${tag}]`;
|
|
52
|
+
if (s.frequency === "monthly") return `monthly day=${s.monthly_day ?? "last"} [${tag}]`;
|
|
53
|
+
return `${s.frequency} [${tag}]`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A YYYY-MM-DD typed by a South African becomes the SAST day boundary, not
|
|
58
|
+
* UTC's — otherwise `--from 2026-09-01` silently starts at 02:00 SAST.
|
|
59
|
+
* `end` covers the whole day. Full ISO strings pass through untouched.
|
|
60
|
+
*/
|
|
61
|
+
export function sastRange(from, to) {
|
|
62
|
+
const day = /^\d{4}-\d{2}-\d{2}$/;
|
|
63
|
+
const start = day.test(from) ? new Date(`${from}T00:00:00+02:00`) : new Date(from);
|
|
64
|
+
const end = day.test(to) ? new Date(`${to}T23:59:59.999+02:00`) : new Date(to);
|
|
65
|
+
if (Number.isNaN(start.getTime())) throw new Error(`--from "${from}" is not a date`);
|
|
66
|
+
if (Number.isNaN(end.getTime())) throw new Error(`--to "${to}" is not a date`);
|
|
67
|
+
if (start >= end) throw new Error("--from must be before --to");
|
|
68
|
+
return { start: start.toISOString(), end: end.toISOString() };
|
|
69
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseWeekday, parseMonthlyDay, schedulesFromOpts, describeSchedule, sastRange } from "./insights-config.js";
|
|
4
|
+
|
|
5
|
+
test("weekday parsing: names, abbreviations, digits, and the bare default", () => {
|
|
6
|
+
assert.equal(parseWeekday("monday"), 1);
|
|
7
|
+
assert.equal(parseWeekday("Fri"), 5);
|
|
8
|
+
assert.equal(parseWeekday("0"), 0);
|
|
9
|
+
assert.equal(parseWeekday(true), 1);
|
|
10
|
+
assert.throws(() => parseWeekday("someday"), /--weekly expects/);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("monthly day: last, 1-28, bare default; 29+ refused (the scheduler never fires them)", () => {
|
|
14
|
+
assert.equal(parseMonthlyDay("last"), "last");
|
|
15
|
+
assert.equal(parseMonthlyDay("15"), 15);
|
|
16
|
+
assert.equal(parseMonthlyDay(true), "last");
|
|
17
|
+
assert.throws(() => parseMonthlyDay("31"), /1-28/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("no cadence flag → null (server keeps existing / applies default)", () => {
|
|
21
|
+
assert.equal(schedulesFromOpts({}), null);
|
|
22
|
+
assert.equal(schedulesFromOpts({ report: "advanced" }), null);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("cadence flags build scheduler-shaped entries, store-only applies to all", () => {
|
|
26
|
+
const s = schedulesFromOpts({ daily: true, weekly: "monday", monthly: "last", report: "advanced", storeOnly: true });
|
|
27
|
+
assert.deepEqual(s, [
|
|
28
|
+
{ frequency: "daily", report_type: "advanced", store_only: true },
|
|
29
|
+
{ frequency: "weekly", weekly_day: 1, report_type: "advanced", store_only: true },
|
|
30
|
+
{ frequency: "monthly", monthly_day: "last", report_type: "advanced", store_only: true },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("bad report type is refused before anything reaches the server", () => {
|
|
35
|
+
assert.throws(() => schedulesFromOpts({ daily: true, report: "fancy" }), /--report must be/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("describeSchedule speaks the scheduler's vocabulary", () => {
|
|
39
|
+
assert.equal(describeSchedule({ frequency: "daily", report_type: "standard", store_only: true }), "daily [standard/store-only]");
|
|
40
|
+
assert.equal(describeSchedule({ frequency: "weekly", weekly_day: 3 }), "weekly Wednesday [standard]");
|
|
41
|
+
assert.equal(describeSchedule({ frequency: "weekly" }), "weekly Monday [standard]");
|
|
42
|
+
assert.equal(describeSchedule({ frequency: "monthly", report_type: "advanced" }), "monthly day=last [advanced]");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("date-only --from/--to are SAST day boundaries, not UTC", () => {
|
|
46
|
+
const r = sastRange("2026-09-01", "2026-09-07");
|
|
47
|
+
assert.equal(r.start, "2026-08-31T22:00:00.000Z");
|
|
48
|
+
assert.equal(r.end, "2026-09-07T21:59:59.999Z");
|
|
49
|
+
assert.throws(() => sastRange("2026-09-07", "2026-09-01"), /before/);
|
|
50
|
+
assert.throws(() => sastRange("yesterday", "2026-09-01"), /not a date/);
|
|
51
|
+
});
|
package/src/update-check.js
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
// can never corrupt piped / --json output) when the installed version is older
|
|
5
5
|
// than the latest published on npm.
|
|
6
6
|
//
|
|
7
|
+
// ⚠️ It used to be gated on `process.stderr.isTTY` as well, which meant it was
|
|
8
|
+
// invisible to exactly the audience that most needs it: an AI assistant running
|
|
9
|
+
// `flowiq` through a tool call gets a PIPE, not a TTY, so isTTY is undefined and
|
|
10
|
+
// the hint never printed. Teammates' Claude sessions could therefore run a
|
|
11
|
+
// months-old CLI forever without ever being told. Writing to stderr is already
|
|
12
|
+
// the whole safety property (it cannot corrupt piped stdout or --json), so the
|
|
13
|
+
// TTY gate bought nothing and cost everything. Removed 9 Sep 2026.
|
|
14
|
+
//
|
|
7
15
|
// The nudge is driven by a CACHED value read SYNCHRONOUSLY — zero network on the
|
|
8
16
|
// hot path, so no command is ever slowed. The cache is refreshed at most once a
|
|
9
17
|
// day by a DETACHED, unref'd child process, so even `flowiq --version` returns
|
|
@@ -88,16 +96,42 @@ async function refreshLatest(prevLatest) {
|
|
|
88
96
|
|
|
89
97
|
// Print the nudge (from cache, synchronously) and, if the cache is stale, spawn
|
|
90
98
|
// a detached child to refresh it for the NEXT run. Never throws, never blocks.
|
|
99
|
+
/** The cached npm `latest` + when it was refreshed — used by `flowiq doctor`. */
|
|
100
|
+
export function cachedLatest() {
|
|
101
|
+
const c = readCacheSync();
|
|
102
|
+
return { latest: c?.latest || null, checkedAt: c?.checked_at || null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Ask npm for the latest version NOW (doctor only — never on the hot path). */
|
|
106
|
+
export async function fetchLatestNow(timeoutMs = FETCH_TIMEOUT_MS) {
|
|
107
|
+
const ctrl = new AbortController();
|
|
108
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetch(REGISTRY, { signal: ctrl.signal, headers: { Accept: "application/json" } });
|
|
111
|
+
if (!res.ok) return null;
|
|
112
|
+
const body = await res.json();
|
|
113
|
+
if (body?.version) await writeCache({ checked_at: Date.now(), latest: body.version });
|
|
114
|
+
return body?.version || null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
} finally {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
91
122
|
export function maybeNotifyUpdate(currentVersion) {
|
|
92
123
|
try {
|
|
93
124
|
if (disabled()) return;
|
|
94
125
|
const cache = readCacheSync();
|
|
95
126
|
const latest = cache?.latest;
|
|
96
127
|
|
|
97
|
-
if (latest &&
|
|
128
|
+
if (latest && cmpVersion(latest, currentVersion) > 0) {
|
|
129
|
+
// The marker line is deliberately stable and unambiguous: an assistant
|
|
130
|
+
// reading this in a tool result should update without needing to be asked.
|
|
98
131
|
process.stderr.write(
|
|
99
|
-
`\n ⬆
|
|
100
|
-
`
|
|
132
|
+
`\n ⬆ FLOWIQ CLI OUT OF DATE — installed ${currentVersion}, latest ${latest}\n` +
|
|
133
|
+
` Run this before continuing: npm i -g ${PKG}@latest\n` +
|
|
134
|
+
` (commands still work; newer ones may be missing or behave differently)\n\n`
|
|
101
135
|
);
|
|
102
136
|
}
|
|
103
137
|
|