@wirecat/cli-base 0.47.4 → 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 CHANGED
@@ -8,6 +8,48 @@ 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
+
40
+ ## 0.47.5 — 21.10.2026
41
+
42
+ ### Fixed
43
+
44
+ - **On Linux the OS keyring now means the Secret Service, never the kernel keyring.** `@napi-rs/keyring` silently
45
+ fell back to the in-memory kernel keyring when the Secret Service did not answer (gnome-keyring locked, D-Bus
46
+ timeout): reads of stored secrets came back empty with no hint why, and writes went to memory that a reboot
47
+ clears. `systemKeyring` now pins every entry to the Secret Service, so such a failure throws and `Credentials`
48
+ in `auto` mode warns once and uses its file. What to watch for: a machine with no Secret Service but a working
49
+ kernel keyring now gets the credentials file instead; a secret written to the kernel keyring by an earlier
50
+ version is no longer read and has to be stored again. The warning now reads "could not reach the OS keyring — it
51
+ may be locked, or no keyring service is running (…); using <file> instead, readable only by you".
52
+
11
53
  ## 0.47.4 — 20.10.2026
12
54
 
13
55
  ### Added
@@ -110,8 +110,9 @@ export class Credentials {
110
110
  catch (error) {
111
111
  if (!this.#warned) {
112
112
  this.#warned = true;
113
- this.#warn(`the OS keyring is unavailable (${error instanceof Error ? error.message : String(error)}); ` +
114
- `storing in ${this.#path()} instead, readable only by you`);
113
+ this.#warn(`could not reach the OS keyring — it may be locked, or no keyring service is running ` +
114
+ `(${error instanceof Error ? error.message : String(error)}); ` +
115
+ `using ${this.#path()} instead, readable only by you`);
115
116
  }
116
117
  return undefined;
117
118
  }
package/dist/keyring.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { createRequire } from "node:module";
2
+ // Without a pinned store the binding silently falls back to the kernel keyring when the Secret
3
+ // Service fails (locked, not running): reads miss every stored secret and writes are lost on
4
+ // reboot, and nothing throws, so `Credentials` never falls back to its file. Ignored off Linux.
5
+ const ENTRY_OPTIONS = { linux: { store: "secret-service" } };
2
6
  const require = createRequire(import.meta.url);
3
7
  let cached;
4
8
  // Loaded on first use, not at import: `@napi-rs/keyring` is a native module, and a machine
@@ -7,15 +11,15 @@ const entryClass = () => {
7
11
  cached ??= require("@napi-rs/keyring").Entry;
8
12
  return cached;
9
13
  };
14
+ const entry = (service, account) => new (entryClass())(service, account, ENTRY_OPTIONS);
10
15
  export const systemKeyring = {
11
16
  // Returns null for an absent entry rather than throwing — measured against @napi-rs/keyring
12
17
  // on 2026-09-13, and the difference matters: a throw here means the keychain is unusable.
13
- get: (service, account) => new (entryClass())(service, account).getPassword(),
18
+ get: (service, account) => entry(service, account).getPassword(),
14
19
  set: (service, account, secret) => {
15
- ;
16
- new (entryClass())(service, account).setPassword(secret);
20
+ entry(service, account).setPassword(secret);
17
21
  },
18
- delete: (service, account) => new (entryClass())(service, account).deletePassword(),
22
+ delete: (service, account) => entry(service, account).deletePassword(),
19
23
  };
20
24
  /** An in-memory stand-in. Every test uses this; none can reach the real keychain. */
21
25
  export const memoryKeyring = (initial = {}) => {
@@ -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;
@@ -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) => {
@@ -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";
@@ -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";
@@ -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 with a zone, never epoch milliseconds" },
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" },
@@ -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 with a zone">]>;
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 with a zone">]>;
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>;
@@ -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 with a zone"));
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({
@@ -22,7 +22,7 @@ export const fakeClock = (startMs = 0) => {
22
22
  return {
23
23
  waits,
24
24
  clock: () => elapsed,
25
- now: () => new Date(startMs + elapsed),
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");
@@ -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
 
@@ -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? }`. `complete: false` with
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 with a zone, never epoch milliseconds. A length of time is a number of
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.47.4",
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",