@wirecat/cli-base 0.47.5 → 0.48.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/CHANGELOG.md +29 -0
- package/dist/query/dates.d.ts +9 -0
- package/dist/query/dates.js +54 -0
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.js +1 -1
- package/dist/standards/fields.js +1 -1
- package/dist/standards/log.d.ts +1 -1
- package/dist/standards/output.d.ts +16 -1
- package/dist/standards/output.js +3 -1
- package/dist/testing/index.js +1 -1
- package/docs/standards/README.md +2 -1
- package/docs/standards/output.md +4 -2
- package/docs/standards/time.md +97 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,35 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
|
|
|
8
8
|
Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
|
|
9
9
|
[`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).
|
|
10
10
|
|
|
11
|
+
## 0.48.0 — 21.10.2026
|
|
12
|
+
|
|
13
|
+
### Changed — may break callers
|
|
14
|
+
|
|
15
|
+
- **The handbook has a Time page, and the shared CI runs tests in `Asia/Vladivostok`.** Every time is stored and
|
|
16
|
+
exchanged in UTC; a zone is applied only where a time with no zone comes in and where a person reads one
|
|
17
|
+
([`docs/standards/time.md`](docs/standards/time.md), `WC-TIME-1`…`6`). `WC-OUT-13` now asks for ISO 8601 ending
|
|
18
|
+
in `Z`, not any zone. `node-ci.yml` sets `TZ=Asia/Vladivostok` for `test:coverage`, so a test that passed only
|
|
19
|
+
because CI ran in UTC fails. What to watch for: a repository that moves its `node-ci.yml` pin to this release may
|
|
20
|
+
see tests fail that read or print local dates; those are the bugs the Time page names.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **`wallTime(value, zone, span)` in `@wirecat/cli-base/query`** reads a time with no zone (`2026-10-21T16:03`,
|
|
25
|
+
optional seconds) as the moment it names in an IANA zone, whatever the process zone. A local time that daylight
|
|
26
|
+
saving skips or repeats is refused with `validation_error`, never guessed. It is the input edge `WC-TIME-3` asks
|
|
27
|
+
for; cli-core's `--since-time` and `--at-time` parsers are to move onto it.
|
|
28
|
+
- **`zonedMoment(value, zone, span, { end? })` in `@wirecat/cli-base/query`** reads a typed date or time the way
|
|
29
|
+
`WC-TIME-3` and `WC-TIME-5` ask: a zone the value names wins, a time without one is read in `zone`, a date alone is
|
|
30
|
+
local midnight, or with `end` the last millisecond of that local day.
|
|
31
|
+
- **`meta.timezone` and `meta.window`** are allowed in every envelope: the zone a time without one was read or shown
|
|
32
|
+
in, and the UTC `{ since?, until? }` a date filter used.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **`fakeClock(startMs).now()` starts at `startMs`.** It reported `2 × startMs` plus the time slept, so a test that
|
|
37
|
+
gave a start time saw a different wall time than it set. What to watch for: a test that worked around the doubled
|
|
38
|
+
value now sees the value it passed.
|
|
39
|
+
|
|
11
40
|
## 0.47.5 — 21.10.2026
|
|
12
41
|
|
|
13
42
|
### Fixed
|
package/dist/query/dates.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { type Span } from "./types.js";
|
|
2
2
|
export declare const timezoneOf: (given?: string) => string;
|
|
3
3
|
export declare const dayBoundary: (value: string, zone: string, next: boolean, span: Span) => number;
|
|
4
|
+
/** A time with no zone, read in `zone`. A local time that daylight saving skips or repeats is refused. */
|
|
5
|
+
export declare const wallTime: (value: string, zone: string, span: Span) => number;
|
|
6
|
+
/**
|
|
7
|
+
* A typed date or time as epoch ms. A zone the value names wins; else it is read in `zone`. A date alone is the
|
|
8
|
+
* start of that local day, or with `end` its last millisecond, so an inclusive `<=` keeps the whole day.
|
|
9
|
+
*/
|
|
10
|
+
export declare const zonedMoment: (value: string, zone: string, span: Span, { end }?: {
|
|
11
|
+
end?: boolean | undefined;
|
|
12
|
+
}) => number;
|
|
4
13
|
export interface DateRange {
|
|
5
14
|
lower?: number;
|
|
6
15
|
upper?: number;
|
package/dist/query/dates.js
CHANGED
|
@@ -50,6 +50,60 @@ export const dayBoundary = (value, zone, next, span) => {
|
|
|
50
50
|
queryError("query.invalid-date", span, "this calendar day does not exist in the selected timezone");
|
|
51
51
|
return lo;
|
|
52
52
|
};
|
|
53
|
+
const WALL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/u;
|
|
54
|
+
/** A time with no zone, read in `zone`. A local time that daylight saving skips or repeats is refused. */
|
|
55
|
+
export const wallTime = (value, zone, span) => {
|
|
56
|
+
const [, year, month, day, hour, minute, second = "0", fraction = "0"] = WALL.exec(value) ?? [];
|
|
57
|
+
if (year === undefined)
|
|
58
|
+
queryError("query.invalid-date", span, "use YYYY-MM-DDTHH:mm, with optional seconds");
|
|
59
|
+
const wanted = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second), Number(fraction.padEnd(3, "0")));
|
|
60
|
+
const check = new Date(wanted);
|
|
61
|
+
if (check.getUTCDate() !== Number(day) ||
|
|
62
|
+
check.getUTCHours() !== Number(hour) ||
|
|
63
|
+
check.getUTCMinutes() !== Number(minute) ||
|
|
64
|
+
check.getUTCSeconds() !== Number(second))
|
|
65
|
+
queryError("query.invalid-date", span);
|
|
66
|
+
const formatter = new Intl.DateTimeFormat("en-CA", {
|
|
67
|
+
timeZone: zone,
|
|
68
|
+
year: "numeric",
|
|
69
|
+
month: "2-digit",
|
|
70
|
+
day: "2-digit",
|
|
71
|
+
hour: "2-digit",
|
|
72
|
+
minute: "2-digit",
|
|
73
|
+
second: "2-digit",
|
|
74
|
+
hourCycle: "h23",
|
|
75
|
+
});
|
|
76
|
+
const shown = (instant) => {
|
|
77
|
+
const parts = Object.fromEntries(formatter.formatToParts(instant).map((part) => [part.type, Number(part.value)]));
|
|
78
|
+
return (Date.UTC(parts.year ?? 0, (parts.month ?? 1) - 1, parts.day, parts.hour, parts.minute, parts.second) +
|
|
79
|
+
(((instant % 1000) + 1000) % 1000));
|
|
80
|
+
};
|
|
81
|
+
const found = new Set();
|
|
82
|
+
for (const probe of [wanted - 86_400_000, wanted, wanted + 86_400_000]) {
|
|
83
|
+
const candidate = wanted - (shown(probe) - probe);
|
|
84
|
+
if (shown(candidate) === wanted)
|
|
85
|
+
found.add(candidate);
|
|
86
|
+
}
|
|
87
|
+
if (found.size !== 1)
|
|
88
|
+
queryError("query.invalid-date", span, "this local time is skipped or repeated by daylight saving; add an offset");
|
|
89
|
+
return [...found][0];
|
|
90
|
+
};
|
|
91
|
+
const ZONED = /(?:Z|[+-]\d{2}:?\d{2})$/iu;
|
|
92
|
+
/**
|
|
93
|
+
* A typed date or time as epoch ms. A zone the value names wins; else it is read in `zone`. A date alone is the
|
|
94
|
+
* start of that local day, or with `end` its last millisecond, so an inclusive `<=` keeps the whole day.
|
|
95
|
+
*/
|
|
96
|
+
export const zonedMoment = (value, zone, span, { end = false } = {}) => {
|
|
97
|
+
if (/^\d{4}-\d{2}-\d{2}$/u.test(value))
|
|
98
|
+
return dayBoundary(value, zone, end, span) - (end ? 1 : 0);
|
|
99
|
+
if (!ZONED.test(value))
|
|
100
|
+
return wallTime(value, zone, span);
|
|
101
|
+
const time = Date.parse(value);
|
|
102
|
+
if (!/^\d{4}-\d{2}-\d{2}T/u.test(value) || !Number.isFinite(time))
|
|
103
|
+
queryError("query.invalid-date", span);
|
|
104
|
+
calendar(value.slice(0, 10), span);
|
|
105
|
+
return time;
|
|
106
|
+
};
|
|
53
107
|
const AGO = /^(\d+)(m|h|d)$/u;
|
|
54
108
|
const AGO_MS = { m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
55
109
|
const dayOf = (word, zone, now, span) => {
|
package/dist/query/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* One query language for every list: Lucene syntax over the entity's own fields. The parser is a port of
|
|
3
3
|
* Lucene 9.12.3's; the fields come from the entity schema; `evaluate` is a test oracle, never a list filter.
|
|
4
4
|
*/
|
|
5
|
-
export { type DateRange, dateEndpoint, dateRange, dayBoundary, timezoneOf } from "./dates.js";
|
|
5
|
+
export { type DateRange, dateEndpoint, dateRange, dayBoundary, timezoneOf, wallTime, zonedMoment } from "./dates.js";
|
|
6
6
|
export { type EvaluateOptions, evaluate } from "./evaluate.js";
|
|
7
7
|
export { FIELD_TYPES, type FieldAnnotations, type FieldSpec, type FieldType, OPERATORS_BY_TYPE, QUERY_BACKENDS, type QueryBackend, type QueryRegistry, queryFieldsOf, } from "./fields.js";
|
|
8
8
|
export { fieldsHelp, QUERY_SYNTAX } from "./help.js";
|
package/dist/query/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* One query language for every list: Lucene syntax over the entity's own fields. The parser is a port of
|
|
3
3
|
* Lucene 9.12.3's; the fields come from the entity schema; `evaluate` is a test oracle, never a list filter.
|
|
4
4
|
*/
|
|
5
|
-
export { dateEndpoint, dateRange, dayBoundary, timezoneOf } from "./dates.js";
|
|
5
|
+
export { dateEndpoint, dateRange, dayBoundary, timezoneOf, wallTime, zonedMoment } from "./dates.js";
|
|
6
6
|
export { evaluate } from "./evaluate.js";
|
|
7
7
|
export { FIELD_TYPES, OPERATORS_BY_TYPE, QUERY_BACKENDS, queryFieldsOf, } from "./fields.js";
|
|
8
8
|
export { fieldsHelp, QUERY_SYNTAX } from "./help.js";
|
package/dist/standards/fields.js
CHANGED
|
@@ -4,7 +4,7 @@ const fieldRuleSchema = v.strictObject({ id: word, rule: text });
|
|
|
4
4
|
export const defineFieldRules = (input) => parseRegistry("output field rules", v.array(fieldRuleSchema), input, (rules) => duplicates(rules.map((rule) => rule.id)).map((id) => `"${id}" is listed twice`));
|
|
5
5
|
export const FIELD_RULES = defineFieldRules([
|
|
6
6
|
{ id: "envelope", rule: "every JSON answer is one object: a list `{items, page, …}`, one thing `{item, …}`" },
|
|
7
|
-
{ id: "time", rule: "a time is an ISO 8601 string
|
|
7
|
+
{ id: "time", rule: "a time is an ISO 8601 string in UTC, ending in `Z`, never epoch milliseconds" },
|
|
8
8
|
{ id: "duration", rule: "a length of time is a number of milliseconds in a key ending in `_ms`" },
|
|
9
9
|
{ id: "ref", rule: "a stored thing is named by `ref`; a message's `ref` value is its locator" },
|
|
10
10
|
{ id: "snake-case", rule: "keys are snake_case words, no abbreviations a reader has to decode" },
|
package/dist/standards/log.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export declare const LOG_SEVERITIES: readonly ["trace", "debug", "info", "warn",
|
|
|
7
7
|
* (https://opentelemetry.io/docs/specs/otel/compatibility/logging_trace_context/); `run_id` and `command` are ours.
|
|
8
8
|
*/
|
|
9
9
|
export declare const logRecordSchema: v.StrictObjectSchema<{
|
|
10
|
-
readonly time: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time
|
|
10
|
+
readonly time: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
|
|
11
11
|
readonly severity: v.PicklistSchema<readonly ["trace", "debug", "info", "warn", "error", "fatal"], undefined>;
|
|
12
12
|
/** The registered event id, `provider.request.failed`: never a sentence, so never content. */
|
|
13
13
|
readonly body: v.StringSchema<undefined>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as v from "valibot";
|
|
2
2
|
/** At most two next steps per answer: the one to take, then one alternative. */
|
|
3
3
|
export declare const MAX_NEXT_STEPS = 2;
|
|
4
|
-
export declare const isoTime: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time
|
|
4
|
+
export declare const isoTime: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
|
|
5
5
|
/** The typed name of a stored thing, `msg:telegram/1/2/3` or `note:42`: what every other command takes. */
|
|
6
6
|
export declare const reference: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
|
|
7
7
|
export declare const nextStepSchema: v.VariantSchema<"kind", [v.StrictObjectSchema<{
|
|
@@ -65,6 +65,11 @@ export declare const metaSchema: v.StrictObjectSchema<{
|
|
|
65
65
|
readonly source: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
|
|
66
66
|
}, undefined>, undefined>, undefined>;
|
|
67
67
|
readonly truncated: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
|
|
68
|
+
readonly timezone: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
|
|
69
|
+
readonly window: v.OptionalSchema<v.StrictObjectSchema<{
|
|
70
|
+
readonly since: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
|
|
71
|
+
readonly until: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
|
|
72
|
+
}, undefined>, undefined>;
|
|
68
73
|
}, undefined>;
|
|
69
74
|
export declare const listEnvelopeSchema: <T extends v.GenericSchema>(item: T) => v.StrictObjectSchema<{
|
|
70
75
|
readonly items: v.ArraySchema<T, undefined>;
|
|
@@ -132,6 +137,11 @@ export declare const listEnvelopeSchema: <T extends v.GenericSchema>(item: T) =>
|
|
|
132
137
|
readonly source: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
|
|
133
138
|
}, undefined>, undefined>, undefined>;
|
|
134
139
|
readonly truncated: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
|
|
140
|
+
readonly timezone: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
|
|
141
|
+
readonly window: v.OptionalSchema<v.StrictObjectSchema<{
|
|
142
|
+
readonly since: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
|
|
143
|
+
readonly until: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
|
|
144
|
+
}, undefined>, undefined>;
|
|
135
145
|
}, undefined>;
|
|
136
146
|
}, undefined>;
|
|
137
147
|
export declare const itemEnvelopeSchema: <T extends v.GenericSchema>(item: T) => v.StrictObjectSchema<{
|
|
@@ -189,6 +199,11 @@ export declare const itemEnvelopeSchema: <T extends v.GenericSchema>(item: T) =>
|
|
|
189
199
|
readonly source: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>;
|
|
190
200
|
}, undefined>, undefined>, undefined>;
|
|
191
201
|
readonly truncated: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
|
|
202
|
+
readonly timezone: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "must not be empty">]>, undefined>;
|
|
203
|
+
readonly window: v.OptionalSchema<v.StrictObjectSchema<{
|
|
204
|
+
readonly since: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
|
|
205
|
+
readonly until: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
|
|
206
|
+
}, undefined>, undefined>;
|
|
192
207
|
}, undefined>;
|
|
193
208
|
}, undefined>;
|
|
194
209
|
export type NextStep = v.InferOutput<typeof nextStepSchema>;
|
package/dist/standards/output.js
CHANGED
|
@@ -3,7 +3,7 @@ import { errorCodes } from "../errors.js";
|
|
|
3
3
|
import { dottedId, text } from "./registry.js";
|
|
4
4
|
/** At most two next steps per answer: the one to take, then one alternative. */
|
|
5
5
|
export const MAX_NEXT_STEPS = 2;
|
|
6
|
-
export const isoTime = v.pipe(v.string(), v.isoTimestamp("an ISO 8601 time
|
|
6
|
+
export const isoTime = v.pipe(v.string(), v.isoTimestamp("an ISO 8601 time in UTC"), v.endsWith("Z", "an ISO 8601 time in UTC, ending in `Z`"));
|
|
7
7
|
/** The typed name of a stored thing, `msg:telegram/1/2/3` or `note:42`: what every other command takes. */
|
|
8
8
|
export const reference = v.pipe(v.string(), v.regex(/^[a-z]+:\S+$/, "a reference such as `note:42`"));
|
|
9
9
|
export const nextStepSchema = v.variant("kind", [
|
|
@@ -65,6 +65,8 @@ export const metaSchema = v.strictObject({
|
|
|
65
65
|
complete: v.boolean(),
|
|
66
66
|
issues: v.optional(v.array(issueSchema)),
|
|
67
67
|
truncated: v.optional(v.boolean()),
|
|
68
|
+
timezone: v.optional(text),
|
|
69
|
+
window: v.optional(v.strictObject({ since: v.optional(isoTime), until: v.optional(isoTime) })),
|
|
68
70
|
});
|
|
69
71
|
const nextSteps = v.optional(v.pipe(v.array(nextStepSchema), v.maxLength(MAX_NEXT_STEPS)));
|
|
70
72
|
export const listEnvelopeSchema = (item) => v.strictObject({
|
package/dist/testing/index.js
CHANGED
|
@@ -22,7 +22,7 @@ export const fakeClock = (startMs = 0) => {
|
|
|
22
22
|
return {
|
|
23
23
|
waits,
|
|
24
24
|
clock: () => elapsed,
|
|
25
|
-
now: () => new Date(
|
|
25
|
+
now: () => new Date(elapsed),
|
|
26
26
|
sleep: async (ms, signal, reason) => {
|
|
27
27
|
if (signal?.aborted)
|
|
28
28
|
throw new DOMException("The operation was aborted.", "AbortError");
|
package/docs/standards/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# WireCat CLI standards
|
|
2
2
|
|
|
3
3
|
This handbook holds the rules every WireCat CLI follows: tools, layout, commands, output, MCP, accounts,
|
|
4
|
-
permissions, architecture, tests, security, documents, releases and CI. Read this page first. Then read the page
|
|
4
|
+
permissions, architecture, time, tests, security, documents, releases and CI. Read this page first. Then read the page
|
|
5
5
|
for the area you are about to change.
|
|
6
6
|
|
|
7
7
|
It ships in the `@wirecat/cli-base` package. A CLI that depends on cli-base has it at
|
|
@@ -40,6 +40,7 @@ rules it keeps. A new CLI is ready for its first release when every check under
|
|
|
40
40
|
| [Documents](docs.md) | `DOC` | 20 | User pages and developer pages, generated pages, current facts, changelog |
|
|
41
41
|
| [Releases](releases.md) | `REL` | 16 | `bin/release`, the release lock, versions, release reports, trusted publishing |
|
|
42
42
|
| [CI](ci.md) | `CI` | 12 | Hooks, the pull request budget, full validation, monthly runs |
|
|
43
|
+
| [Time](time.md) | `TIME` | 6 | UTC in the store and in answers, the zone at input and display edges, date-only filters, tests in another zone |
|
|
43
44
|
| [Style](style.md) | `STYLE` | 10 | Comments, TypeScript habits, commit messages |
|
|
44
45
|
| [New CLI](new-cli.md) | — | — | The checklist for starting a new CLI |
|
|
45
46
|
|
package/docs/standards/output.md
CHANGED
|
@@ -80,7 +80,9 @@ JSON Schema 2020-12.
|
|
|
80
80
|
|
|
81
81
|
- **Why:** `run_id` finds the run later, and `complete` says whether the answer can be trusted as whole.
|
|
82
82
|
- **Enforced by:** review only.
|
|
83
|
-
- **Example:** `{ run_id, duration_ms, accounts?, complete, issues?, truncated? }`. `
|
|
83
|
+
- **Example:** `{ run_id, duration_ms, accounts?, complete, issues?, truncated?, timezone?, window? }`. `timezone`
|
|
84
|
+
names the zone a time without one was read or shown in, and `window` the UTC bounds a date filter used
|
|
85
|
+
([Time](time.md)). `complete: false` with
|
|
84
86
|
`issues: [{ code, reason?, source }]` says a source failed or was skipped. `truncated` says a size cap cut the
|
|
85
87
|
answer. Neither one means another page exists: that is `page.has_more`.
|
|
86
88
|
- **Exceptions:** none.
|
|
@@ -147,7 +149,7 @@ JSON Schema 2020-12.
|
|
|
147
149
|
|
|
148
150
|
- **Why:** a caller must not guess whether `0` means "none" or "unknown".
|
|
149
151
|
- **Enforced by:** `nullable-list`.
|
|
150
|
-
- **Example:** a time is an ISO 8601 string
|
|
152
|
+
- **Example:** a time is an ISO 8601 string in UTC, ending in `Z`, never epoch milliseconds ([Time](time.md)). A length of time is a number of
|
|
151
153
|
milliseconds in a key ending in `_ms`. A stored thing is named by `ref`. A key that is absent was not asked for or
|
|
152
154
|
is not known. `null` is known to have no value. `[]` is known to be empty, never `null`. `0` is measured, never a
|
|
153
155
|
stand-in for unknown.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Time
|
|
2
|
+
|
|
3
|
+
## Scope
|
|
4
|
+
|
|
5
|
+
How a CLI keeps, exchanges, reads and shows a moment in time: UTC inside, a zone only at the edges.
|
|
6
|
+
|
|
7
|
+
Rule ids on this page are `WC-TIME-<n>`. How to read a rule, how ids work and which rule wins are in the
|
|
8
|
+
[index](README.md).
|
|
9
|
+
|
|
10
|
+
Related pages: how a time is written in an answer, next to durations and empty values, is in [Output](output.md)
|
|
11
|
+
(`WC-OUT-13`); the store and its migrations are in [Store](store.md); the test sandbox is in [Testing](testing.md).
|
|
12
|
+
|
|
13
|
+
### Words used here
|
|
14
|
+
|
|
15
|
+
- **Edge:** a place where a time enters without a zone (a typed date, a file name, a provider field) or leaves for
|
|
16
|
+
a person to read (human text output).
|
|
17
|
+
- **Zone in use:** the `--timezone` flag when given, else the zone a setting names, else the computer's IANA zone.
|
|
18
|
+
|
|
19
|
+
## Rules
|
|
20
|
+
|
|
21
|
+
### WC-TIME-1 · Store every time in UTC
|
|
22
|
+
|
|
23
|
+
Every time in a database, cache, cursor, receipt, manifest or exported file is UTC: an ISO 8601 string ending in
|
|
24
|
+
`Z`, or epoch milliseconds. Never local wall time, never an offset other than zero.
|
|
25
|
+
|
|
26
|
+
- **Why:** a stored time read on another computer, or after the owner travels, must mean the same moment.
|
|
27
|
+
- **Enforced by:** review only.
|
|
28
|
+
- **Example:** `sent_at INTEGER` holding `1792591380000`, or `"2026-10-21T14:03:00.000Z"`.
|
|
29
|
+
- **Exceptions:** a column that already holds UTC in another unambiguous form (SQLite's `YYYY-MM-DD HH:MM:SS` from
|
|
30
|
+
`CURRENT_TIMESTAMP`, epoch seconds) stays until its table next migrates; its value still leaves as `WC-TIME-2`
|
|
31
|
+
says. A column that may hold local time gets a forward-only migration, or a proof from history that every writer
|
|
32
|
+
wrote UTC.
|
|
33
|
+
- **Applies to:** all CLIs, cli-core and cli-base. **Since:** cli-base 0.48.0. **Rulings:** NEED-1592.
|
|
34
|
+
|
|
35
|
+
### WC-TIME-2 · Exchange every time in UTC, and add a local time only beside it
|
|
36
|
+
|
|
37
|
+
JSON, JSONL and MCP output carry each time as an ISO 8601 string ending in `Z`. A display zone may add a second key,
|
|
38
|
+
`<key>_local`, with the offset of the zone in use, and `meta.timezone` then names that zone. The local key never
|
|
39
|
+
replaces the UTC one.
|
|
40
|
+
|
|
41
|
+
- **Why:** an agent compares and sorts times from many CLIs; one form, in one zone, needs no guessing.
|
|
42
|
+
- **Enforced by:** review only.
|
|
43
|
+
- **Example:** `{ "sent_at": "2026-10-21T14:03:00.000Z", "sent_at_local": "2026-10-21T16:03:00.000+02:00" }` with
|
|
44
|
+
`"meta": { "timezone": "Europe/Madrid", … }`.
|
|
45
|
+
- **Exceptions:** a provider's own text kept as it came (a mail `Date:` header) goes under `provider_metadata`.
|
|
46
|
+
- **Applies to:** all CLIs. **Since:** cli-base 0.48.0. **Rulings:** none.
|
|
47
|
+
|
|
48
|
+
### WC-TIME-3 · Read a time that has no zone in the zone in use, and say which zone that was
|
|
49
|
+
|
|
50
|
+
A typed date, a file name or a provider field without a zone is read in the zone in use. The answer says that zone:
|
|
51
|
+
`meta.timezone` in machine mode, a short line in human text.
|
|
52
|
+
|
|
53
|
+
- **Why:** a silent default turns a wall-clock time into the wrong moment; zm stored 111 meetings two hours late when
|
|
54
|
+
file names were read as UTC.
|
|
55
|
+
- **Enforced by:** review only.
|
|
56
|
+
- **Example:** `zm import 2026-10-21_1603_standup.vtt` on a computer in `Europe/Madrid` stores `14:03Z`, and says
|
|
57
|
+
"file times read in Europe/Madrid".
|
|
58
|
+
- **Exceptions:** a provider documented to send UTC without a marker is read as UTC, with a link to that
|
|
59
|
+
documentation next to the code. A local time that daylight saving makes ambiguous or nonexistent is refused, never
|
|
60
|
+
guessed.
|
|
61
|
+
- **Applies to:** all CLIs. **Since:** cli-base 0.48.0. **Rulings:** NEED-1592.
|
|
62
|
+
|
|
63
|
+
### WC-TIME-4 · Show times to a person in the zone in use, and say which zone
|
|
64
|
+
|
|
65
|
+
- **Why:** a person reads wall-clock time; a UTC time shown without a word reads as local.
|
|
66
|
+
- **Enforced by:** review only.
|
|
67
|
+
- **Example:** `messages list` prints `16:03` and ends with `times in Europe/Madrid`.
|
|
68
|
+
- **Exceptions:** none.
|
|
69
|
+
- **Applies to:** all CLIs. **Since:** cli-base 0.48.0. **Rulings:** none.
|
|
70
|
+
|
|
71
|
+
### WC-TIME-5 · A date with no time covers the whole local day, and the answer gives the window in UTC
|
|
72
|
+
|
|
73
|
+
`--since-time 2026-09-01` starts at local midnight of the zone in use; `--until-time 2026-09-01` ends at the next
|
|
74
|
+
local midnight. The answer gives the window it used, in UTC.
|
|
75
|
+
|
|
76
|
+
- **Why:** "messages of 1 September" means the owner's day, not the UTC day, which starts hours earlier or later.
|
|
77
|
+
- **Enforced by:** review only.
|
|
78
|
+
- **Example:** in `Asia/Vladivostok`, `--since-time 2026-09-01` gives `meta.window.since` = `2026-08-31T14:00:00.000Z`.
|
|
79
|
+
- **Exceptions:** none.
|
|
80
|
+
- **Applies to:** all CLIs. **Since:** cli-base 0.48.0. **Rulings:** none.
|
|
81
|
+
|
|
82
|
+
### WC-TIME-6 · Run the tests in a zone that is not UTC, and prove stored values do not depend on it
|
|
83
|
+
|
|
84
|
+
The shared `node-ci.yml` runs `test:coverage` with `TZ=Asia/Vladivostok` (UTC+10, no daylight saving). Each
|
|
85
|
+
repository that stores times has a test that writes the same input under two zones and gets identical stored values.
|
|
86
|
+
|
|
87
|
+
- **Why:** a suite that runs only in UTC passes for every bug on this page.
|
|
88
|
+
- **Enforced by:** `node-ci.yml`.
|
|
89
|
+
- **Example:** set `process.env.TZ` to `Asia/Vladivostok`, then to `America/Los_Angeles`, write the same message each
|
|
90
|
+
time, and compare the rows. Node applies a changed `TZ` at once.
|
|
91
|
+
- **Exceptions:** a repository that keeps no time skips the second test.
|
|
92
|
+
- **Applies to:** all CLIs, cli-core and cli-base. **Since:** cli-base 0.48.0. **Rulings:** none.
|
|
93
|
+
|
|
94
|
+
## Sources
|
|
95
|
+
|
|
96
|
+
- The owner's ruling of 21.10.2026: every time is stored and exchanged in UTC, in every repository.
|
|
97
|
+
- NEED-1592: zm reads file name times in the computer's zone.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-base",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|