@mostra/cli 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/README.md +18 -2
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +79 -0
- package/dist/bin.js.map +1 -0
- package/dist/bootstrap-DfEe_yPr.js +113 -0
- package/dist/bootstrap-DfEe_yPr.js.map +1 -0
- package/dist/exit-CBIf8nAU.js +79 -0
- package/dist/exit-CBIf8nAU.js.map +1 -0
- package/dist/flush-BCuWs0Zh.js +718 -0
- package/dist/flush-BCuWs0Zh.js.map +1 -0
- package/dist/index.d.ts +230 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/reference/cli.md +977 -0
- package/dist/reference/diagnostics.json +1264 -0
- package/dist/reference/errors/MOSTRA_A11Y_MINOR.md +23 -0
- package/dist/reference/errors/MOSTRA_A11Y_SERIOUS.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_CANCELLED.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_EVENT_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_EXPORT_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_INTERRUPTED.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_RUN_NOT_FOUND.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_THREW.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_TOO_LARGE.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_UNKNOWN_FUNCTION.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_UNSERIALIZABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_DIR_NOT_EMPTY.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_ENTRY_AMBIGUOUS.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_NOT_FOUND.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_NO_ENTRY.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_NO_PAGE.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_SOURCE_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_SOURCE_UNREADABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_TEMPLATE_UNKNOWN.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_VERSION_COLLECTED.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_VERSION_NOT_FOUND.md +23 -0
- package/dist/reference/errors/MOSTRA_BUILD_CRASHED.md +23 -0
- package/dist/reference/errors/MOSTRA_BUILD_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_BUILD_TIMEOUT.md +23 -0
- package/dist/reference/errors/MOSTRA_BUILD_UNRESOLVED.md +23 -0
- package/dist/reference/errors/MOSTRA_CLI_CONFIG_UNWRITABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_CLI_UNKNOWN_COMMAND.md +23 -0
- package/dist/reference/errors/MOSTRA_CLI_USAGE.md +23 -0
- package/dist/reference/errors/MOSTRA_CONSOLE_ERROR.md +23 -0
- package/dist/reference/errors/MOSTRA_CONSOLE_WARNING.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_INSTALL_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_NOT_FOUND.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_OFFLINE.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_SPEC_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_TIMEOUT.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_WARM_CONFLICT.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_BIND_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_BROWSER_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_BUNDLE_DOWNLOAD_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_BUNDLE_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_BUNDLE_UNAVAILABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_NODE_UNSUPPORTED.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_PORT_TAKEN.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_RUNTIME_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_SERVICE_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_SHELL_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_WARM_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_CONFLICT.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_DUPLICATE_ID.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_ID_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_ID_UNSTABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_MUTATION_ID_REUSED.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_SHARED.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_TOO_LARGE.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_UNBOUND.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_DISK_FULL.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_GC_INCOMPLETE.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_GONE.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_INTEGRITY.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_LEDGER_UNREADABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_LOCKED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_MISMATCH.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_NOT_ALWAYS_ON.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_NOT_OWNED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_SOCKET_TOO_LONG.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_TOO_NEW.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_CONNECTION_CLOSED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_CURSOR_COMPACTED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_IDENTITY_UNAVAILABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_INITIALIZE_ORDER.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_INTERNAL_ERROR.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_INVALID_PARAMS.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_MALFORMED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_MESSAGE_TOO_LARGE.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_METHOD_UNKNOWN.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_NOTIFICATION_UNSUPPORTED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_PORT_UNSUPPORTED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_REQUEST_ID_REUSED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_REVISION_UNSUPPORTED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_SHUTDOWN_REFUSED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_SUBSCRIPTION_UNKNOWN.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_TIMEOUT.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_CONTENT_TYPE.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_CSRF_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_HEADERS_TOO_LARGE.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_HOST_REFUSED.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_NOT_SERVED.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_ORIGIN_REFUSED.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_RATE_LIMITED.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_REQUEST_MALFORMED.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_SCOPE_DENIED.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_SESSION_CONFLICT.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_SESSION_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_TOKEN_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_TOKEN_REVOKED.md +23 -0
- package/dist/reference/errors/MOSTRA_LIVE_SOURCE_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_LIVE_TOO_LARGE.md +23 -0
- package/dist/reference/errors/MOSTRA_MANIFEST_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_MANIFEST_NOT_LITERAL.md +23 -0
- package/dist/reference/errors/MOSTRA_MANIFEST_UNKNOWN_KEY.md +23 -0
- package/dist/reference/errors/MOSTRA_NETWORK_EXTERNAL_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_NETWORK_REQUEST_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_PLATFORM_UNSUPPORTED.md +23 -0
- package/dist/reference/errors/MOSTRA_PUBLISH_ID_CONFLICT.md +23 -0
- package/dist/reference/errors/MOSTRA_PUBLISH_SOURCE_CHANGED.md +23 -0
- package/dist/reference/errors/MOSTRA_PUBLISH_VERIFY_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_REGRESSION_ACTION_REMOVED.md +23 -0
- package/dist/reference/errors/MOSTRA_REGRESSION_ARIA.md +23 -0
- package/dist/reference/errors/MOSTRA_REGRESSION_FIELD_REMOVED.md +23 -0
- package/dist/reference/errors/MOSTRA_REGRESSION_LIVE_REMOVED.md +23 -0
- package/dist/reference/errors/MOSTRA_REGRESSION_PAGE_REMOVED.md +23 -0
- package/dist/reference/errors/MOSTRA_REGRESSION_PIXELS.md +23 -0
- package/dist/reference/errors/MOSTRA_RUNTIME_BLANK.md +23 -0
- package/dist/reference/errors/MOSTRA_RUNTIME_NOT_READY.md +23 -0
- package/dist/reference/errors/MOSTRA_RUNTIME_PAGE_ERROR.md +23 -0
- package/dist/reference/errors/MOSTRA_RUNTIME_WORKER_CRASHED.md +23 -0
- package/dist/reference/errors/MOSTRA_RUNTIME_WORKER_PARKED.md +23 -0
- package/dist/reference/errors/MOSTRA_SECRET_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_SMOKE_BUDGET.md +23 -0
- package/dist/reference/errors/MOSTRA_SMOKE_NO_EFFECT.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_ALREADY.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_BLOCKED.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_CHANNEL_DENIED.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_NOT_FOUND.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_POLICY_NONE.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_REVISION_MOVED.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_SIGNAL_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_VIEW_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_TARGET_UNAVAILABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_TYPE_ERROR.md +23 -0
- package/dist/reference/errors/MOSTRA_TYPE_UNTYPED_PACKAGE.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_ACTION_SKIPPED.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_BASELINE_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_CRASHED.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_LIVE_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_NO_BASELINE.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_SECRET_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_SKIP_CONSEQUENCE.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_TIMEOUT.md +23 -0
- package/dist/reference/telemetry.md +82 -0
- package/dist/run-BJaRnz-Q.js +3824 -0
- package/dist/run-BJaRnz-Q.js.map +1 -0
- package/dist/run-CMyjOm0Q.js +2 -0
- package/dist/telemetry/flush-main.d.ts +1 -0
- package/dist/telemetry/flush-main.js +14 -0
- package/dist/telemetry/flush-main.js.map +1 -0
- package/package.json +62 -3
- package/src/bin.ts +70 -0
- package/src/bootstrap.ts +156 -0
- package/src/browser.ts +186 -0
- package/src/client.ts +313 -0
- package/src/command.ts +147 -0
- package/src/commands/admin.ts +791 -0
- package/src/commands/authoring.ts +244 -0
- package/src/commands/dev.ts +106 -0
- package/src/commands/index.ts +71 -0
- package/src/commands/interaction.ts +288 -0
- package/src/commands/shared.ts +71 -0
- package/src/commands/viewing.ts +153 -0
- package/src/docs.ts +111 -0
- package/src/exit.ts +89 -0
- package/src/help.ts +170 -0
- package/src/home.ts +111 -0
- package/src/index.ts +23 -0
- package/src/node-gate.ts +15 -0
- package/src/parse.ts +246 -0
- package/src/run.ts +387 -0
- package/src/telemetry/decision.ts +101 -0
- package/src/telemetry/events.ts +147 -0
- package/src/telemetry/flush-main.ts +13 -0
- package/src/telemetry/flush.ts +143 -0
- package/src/telemetry/harness.ts +39 -0
- package/src/telemetry/index.ts +318 -0
- package/src/telemetry/store.ts +379 -0
- package/src/telemetry/timezone.ts +72 -0
- package/src/telemetry/words.ts +87 -0
- package/src/telemetry/zone-sets.ts +182 -0
- package/src/telemetry/zones.generated.ts +720 -0
- package/src/version.ts +43 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry's files in the CLI's config directory (D140 item 5): `telemetry.json` (the choice,
|
|
3
|
+
* when the notice was shown, when a flush last started), `telemetry-id` (the random install ID)
|
|
4
|
+
* and `telemetry-queue.ndjson` (events waiting to be sent). The directory is 0700 and the files
|
|
5
|
+
* 0600; `telemetry.json` and the queue are written by rename. The install ID is made by an
|
|
6
|
+
* exclusive create (a hard link that fails when the file exists), so processes on a first run
|
|
7
|
+
* agree on one ID whatever happens to the lock. Every other change goes through one lock file,
|
|
8
|
+
* owned by a token and taken over only when its holder has died, so a flusher never loses an
|
|
9
|
+
* event appended while it sends.
|
|
10
|
+
*/
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import {
|
|
13
|
+
chmodSync,
|
|
14
|
+
closeSync,
|
|
15
|
+
fstatSync,
|
|
16
|
+
linkSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
openSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
renameSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
/** What `telemetry.json` holds. */
|
|
27
|
+
export interface TelemetrySettings {
|
|
28
|
+
/** The person's choice: `mostra telemetry on|off`, or their answer to the question. */
|
|
29
|
+
readonly choice?: "on" | "off";
|
|
30
|
+
/**
|
|
31
|
+
* The random install ID, made on the first run that sends; deleted when telemetry turns off.
|
|
32
|
+
* Read from `telemetry-id`, never written into `telemetry.json`.
|
|
33
|
+
*/
|
|
34
|
+
readonly installId?: string;
|
|
35
|
+
/**
|
|
36
|
+
* When the notice of what is sent was shown where sending follows: the default notice, the
|
|
37
|
+
* question, or `mostra telemetry on`'s. A `default`-consent event needs it (Opus review
|
|
38
|
+
* finding 3).
|
|
39
|
+
*/
|
|
40
|
+
readonly noticeShownAt?: string;
|
|
41
|
+
/**
|
|
42
|
+
* When the ask-first notice ("Nothing is sent unless you run: mostra telemetry on") was
|
|
43
|
+
* shown. It never stands for the default notice: a later run where telemetry is on by default
|
|
44
|
+
* still shows that one before anything is sent.
|
|
45
|
+
*/
|
|
46
|
+
readonly askFirstNoticeShownAt?: string;
|
|
47
|
+
/** When a flush last started. */
|
|
48
|
+
readonly lastFlushAt?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The settings, or why they can't be read (which means off, D140 item 5). */
|
|
52
|
+
export type ReadSettings =
|
|
53
|
+
| { readonly ok: true; readonly settings: TelemetrySettings }
|
|
54
|
+
| { readonly ok: false; readonly file: string };
|
|
55
|
+
|
|
56
|
+
/** A lock older than this whose owner no longer runs is taken over as stale. */
|
|
57
|
+
const STALE_LOCK_MS = 10_000;
|
|
58
|
+
/**
|
|
59
|
+
* A lock older than this is taken over whoever it names: a held section takes milliseconds, and
|
|
60
|
+
* a PID can name an unrelated live process once the owner died (in a container, PID 1 always
|
|
61
|
+
* runs), which would otherwise hold every later command up forever (Opus review finding 5).
|
|
62
|
+
*/
|
|
63
|
+
const DEAD_LOCK_MS = 60_000;
|
|
64
|
+
/** The queue's bounds (D140 item 6). */
|
|
65
|
+
export const QUEUE_MAX_EVENTS = 1000;
|
|
66
|
+
export const QUEUE_MAX_AGE_MS: number = 7 * 24 * 60 * 60 * 1000;
|
|
67
|
+
|
|
68
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
69
|
+
|
|
70
|
+
/** Telemetry's files in one config directory. */
|
|
71
|
+
export class TelemetryStore {
|
|
72
|
+
readonly dir: string;
|
|
73
|
+
readonly settingsFile: string;
|
|
74
|
+
readonly queueFile: string;
|
|
75
|
+
readonly idFile: string;
|
|
76
|
+
readonly #lockFile: string;
|
|
77
|
+
|
|
78
|
+
constructor(dir: string) {
|
|
79
|
+
this.dir = dir;
|
|
80
|
+
this.settingsFile = join(dir, "telemetry.json");
|
|
81
|
+
this.queueFile = join(dir, "telemetry-queue.ndjson");
|
|
82
|
+
this.idFile = join(dir, "telemetry-id");
|
|
83
|
+
this.#lockFile = join(dir, "telemetry.lock");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The settings; a file that isn't telemetry settings is unreadable, never guessed at. */
|
|
87
|
+
read(): ReadSettings {
|
|
88
|
+
const id = this.#readId();
|
|
89
|
+
if (id === null) return { ok: false, file: this.idFile };
|
|
90
|
+
let text: string;
|
|
91
|
+
try {
|
|
92
|
+
text = readFileSync(this.settingsFile, "utf8");
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
95
|
+
return { ok: true, settings: id !== undefined ? { installId: id } : {} };
|
|
96
|
+
return { ok: false, file: this.settingsFile };
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const data = JSON.parse(text) as Record<string, unknown>;
|
|
100
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) throw new Error();
|
|
101
|
+
const { choice, noticeShownAt, askFirstNoticeShownAt, lastFlushAt } = data;
|
|
102
|
+
const installId = id;
|
|
103
|
+
if (choice !== undefined && choice !== "on" && choice !== "off") throw new Error();
|
|
104
|
+
// An install ID belongs in `telemetry-id`; one here is a file Mostra didn't write.
|
|
105
|
+
if (data["installId"] !== undefined) throw new Error();
|
|
106
|
+
if (noticeShownAt !== undefined && typeof noticeShownAt !== "string") throw new Error();
|
|
107
|
+
if (askFirstNoticeShownAt !== undefined && typeof askFirstNoticeShownAt !== "string")
|
|
108
|
+
throw new Error();
|
|
109
|
+
if (lastFlushAt !== undefined && typeof lastFlushAt !== "string") throw new Error();
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
settings: {
|
|
113
|
+
...(choice !== undefined ? { choice } : {}),
|
|
114
|
+
...(installId !== undefined ? { installId } : {}),
|
|
115
|
+
...(noticeShownAt !== undefined ? { noticeShownAt } : {}),
|
|
116
|
+
...(askFirstNoticeShownAt !== undefined ? { askFirstNoticeShownAt } : {}),
|
|
117
|
+
...(lastFlushAt !== undefined ? { lastFlushAt } : {}),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
} catch {
|
|
121
|
+
return { ok: false, file: this.settingsFile };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Changes the settings under the lock and returns what they became; `undefined` when the file
|
|
127
|
+
* can't be read (nothing is written over it).
|
|
128
|
+
*/
|
|
129
|
+
update(change: (current: TelemetrySettings) => TelemetrySettings): TelemetrySettings | undefined {
|
|
130
|
+
return this.locked(() => {
|
|
131
|
+
const current = this.read();
|
|
132
|
+
if (!current.ok) return undefined;
|
|
133
|
+
const next = change(current.settings);
|
|
134
|
+
this.#write(next);
|
|
135
|
+
return next;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The install ID, made when there is none yet. A new ID is written to a file of this process's
|
|
141
|
+
* own, then hard-linked to `telemetry-id`, which fails when the file exists: of any processes
|
|
142
|
+
* making one at once exactly one wins, and the others read the winner's. `undefined` when the
|
|
143
|
+
* settings can't be read.
|
|
144
|
+
*/
|
|
145
|
+
installId(): { readonly id: string; readonly created: boolean } | undefined {
|
|
146
|
+
const current = this.read();
|
|
147
|
+
if (!current.ok) return undefined;
|
|
148
|
+
if (current.settings.installId !== undefined)
|
|
149
|
+
return { id: current.settings.installId, created: false };
|
|
150
|
+
const candidate = `${this.idFile}.${process.pid}.${randomUUID()}.tmp`;
|
|
151
|
+
const id = randomUUID();
|
|
152
|
+
try {
|
|
153
|
+
this.#prepareDir();
|
|
154
|
+
writeFileSync(candidate, `${id}\n`, { mode: 0o600 });
|
|
155
|
+
linkSync(candidate, this.idFile);
|
|
156
|
+
return { id, created: true };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return undefined;
|
|
159
|
+
const winner = this.#readId();
|
|
160
|
+
return winner ? { id: winner, created: false } : undefined;
|
|
161
|
+
} finally {
|
|
162
|
+
rmSync(candidate, { force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The install ID in `telemetry-id`: `undefined` when there is none, `null` when it's damaged. */
|
|
167
|
+
#readId(): string | undefined | null {
|
|
168
|
+
let text: string;
|
|
169
|
+
try {
|
|
170
|
+
text = readFileSync(this.idFile, "utf8");
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? undefined : null;
|
|
173
|
+
}
|
|
174
|
+
const id = text.trim();
|
|
175
|
+
return UUID.test(id) ? id : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Replaces a file that can't be read with these settings; only `telemetry on|off` do, as the
|
|
180
|
+
* person's explicit choice. Call it holding the lock.
|
|
181
|
+
*/
|
|
182
|
+
reset(settings: TelemetrySettings): TelemetrySettings {
|
|
183
|
+
const { installId: _id, ...rest } = settings;
|
|
184
|
+
this.#write(rest);
|
|
185
|
+
// A damaged install ID goes too; `on` makes a new one on its first send.
|
|
186
|
+
rmSync(this.idFile, { force: true });
|
|
187
|
+
if (settings.choice === "off") rmSync(this.queueFile, { force: true });
|
|
188
|
+
return rest;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Turns telemetry off: the choice, and the install ID and the queue deleted at once. */
|
|
192
|
+
turnOff(): boolean {
|
|
193
|
+
return (
|
|
194
|
+
this.locked(() => {
|
|
195
|
+
const current = this.read();
|
|
196
|
+
if (!current.ok) return false;
|
|
197
|
+
const { installId: _id, ...rest } = current.settings;
|
|
198
|
+
this.#write({ ...rest, choice: "off" });
|
|
199
|
+
rmSync(this.idFile, { force: true });
|
|
200
|
+
rmSync(this.queueFile, { force: true });
|
|
201
|
+
return true;
|
|
202
|
+
}) ?? false
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Appends events to the queue, keeping its bounds. */
|
|
207
|
+
append(lines: readonly string[], now: number): void {
|
|
208
|
+
if (lines.length === 0) return;
|
|
209
|
+
this.locked(() => {
|
|
210
|
+
const kept = bounded([...this.#queued(), ...lines], now);
|
|
211
|
+
this.#writeQueue(kept);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The queued events, within their bounds. */
|
|
216
|
+
queued(now: number): string[] {
|
|
217
|
+
return bounded(this.#queued(), now);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Removes the lines a flush sent from the head of the queue, after pruning what expired or
|
|
222
|
+
* fell outside the bounds, as {@link queued} did; how many sent lines were removed (0 when the
|
|
223
|
+
* lock can't be had). A flusher stops when this is 0, so it never sends a batch twice.
|
|
224
|
+
*/
|
|
225
|
+
drop(sent: readonly string[], now: number): number {
|
|
226
|
+
return (
|
|
227
|
+
this.locked(() => {
|
|
228
|
+
const queued = bounded(this.#queued(), now);
|
|
229
|
+
let index = 0;
|
|
230
|
+
while (index < sent.length && queued[index] === sent[index]) index += 1;
|
|
231
|
+
this.#writeQueue(queued.slice(index));
|
|
232
|
+
return index;
|
|
233
|
+
}) ?? 0
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#queued(): string[] {
|
|
238
|
+
try {
|
|
239
|
+
return readFileSync(this.queueFile, "utf8")
|
|
240
|
+
.split("\n")
|
|
241
|
+
.filter((line) => line.length > 0);
|
|
242
|
+
} catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#writeQueue(lines: readonly string[]): void {
|
|
248
|
+
if (lines.length === 0) {
|
|
249
|
+
rmSync(this.queueFile, { force: true });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const temporary = `${this.queueFile}.${process.pid}.tmp`;
|
|
253
|
+
writeFileSync(temporary, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
254
|
+
renameSync(temporary, this.queueFile);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#write(settings: TelemetrySettings): void {
|
|
258
|
+
const { installId: _id, ...stored } = settings;
|
|
259
|
+
const temporary = `${this.settingsFile}.${process.pid}.tmp`;
|
|
260
|
+
writeFileSync(temporary, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
|
261
|
+
renameSync(temporary, this.settingsFile);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#prepareDir(): void {
|
|
265
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
266
|
+
try {
|
|
267
|
+
chmodSync(this.dir, 0o700);
|
|
268
|
+
} catch {
|
|
269
|
+
// A directory someone else owns: the files below fail or succeed on their own terms.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Runs `fn` holding the lock file; `undefined` when the lock can't be had within 2 s, or the
|
|
275
|
+
* directory or a file in it can't be written (a full disk, a directory someone else owns):
|
|
276
|
+
* telemetry never stops a command (D140 item 5). The lock
|
|
277
|
+
* holds its owner's PID and a token of its own, is released only by its owner, and is taken
|
|
278
|
+
* over when it is older than 10 s and its owner is no longer running (a crash), or older than
|
|
279
|
+
* 60 s whoever it names.
|
|
280
|
+
*/
|
|
281
|
+
locked<T>(fn: () => T): T | undefined {
|
|
282
|
+
try {
|
|
283
|
+
this.#prepareDir();
|
|
284
|
+
} catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
const token = `${process.pid} ${randomUUID()}`;
|
|
288
|
+
const deadline = Date.now() + 2000;
|
|
289
|
+
for (;;) {
|
|
290
|
+
try {
|
|
291
|
+
writeFileSync(this.#lockFile, token, { flag: "wx", mode: 0o600 });
|
|
292
|
+
break;
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return undefined;
|
|
295
|
+
if (this.#breakStale()) continue;
|
|
296
|
+
if (Date.now() > deadline) return undefined;
|
|
297
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
return fn();
|
|
302
|
+
} catch {
|
|
303
|
+
return undefined;
|
|
304
|
+
} finally {
|
|
305
|
+
this.#release(token);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Removes the lock when it is still this owner's. */
|
|
310
|
+
#release(token: string): void {
|
|
311
|
+
try {
|
|
312
|
+
if (readFileSync(this.#lockFile, "utf8") === token) rmSync(this.#lockFile, { force: true });
|
|
313
|
+
} catch {
|
|
314
|
+
// Gone already.
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Takes a crashed owner's lock away; `true` when the caller should try again at once. The lock
|
|
320
|
+
* is moved aside under a name of this process's own (a rename is atomic, so of two processes
|
|
321
|
+
* breaking it one gets it), and a lock that turns out to be another, live owner's goes back.
|
|
322
|
+
*/
|
|
323
|
+
#breakStale(): boolean {
|
|
324
|
+
// One descriptor for the age and the contents, so both describe the same file.
|
|
325
|
+
let seen: string;
|
|
326
|
+
let age: number;
|
|
327
|
+
let handle: number | undefined;
|
|
328
|
+
try {
|
|
329
|
+
handle = openSync(this.#lockFile, "r");
|
|
330
|
+
age = Date.now() - fstatSync(handle).mtimeMs;
|
|
331
|
+
if (age <= STALE_LOCK_MS) return false;
|
|
332
|
+
seen = readFileSync(handle, "utf8");
|
|
333
|
+
} catch {
|
|
334
|
+
return true;
|
|
335
|
+
} finally {
|
|
336
|
+
if (handle !== undefined) closeSync(handle);
|
|
337
|
+
}
|
|
338
|
+
if (age <= DEAD_LOCK_MS && ownerAlive(seen)) return false;
|
|
339
|
+
const aside = `${this.#lockFile}.${process.pid}.${randomUUID()}.stale`;
|
|
340
|
+
try {
|
|
341
|
+
renameSync(this.#lockFile, aside);
|
|
342
|
+
} catch {
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
if (readFileSync(aside, "utf8") !== seen) linkSync(aside, this.#lockFile);
|
|
347
|
+
} catch {
|
|
348
|
+
// Another process holds a new lock already; ours goes, and the caller waits for theirs.
|
|
349
|
+
} finally {
|
|
350
|
+
rmSync(aside, { force: true });
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Whether the process a lock names still runs; a lock whose owner can't be read counts as live. */
|
|
357
|
+
function ownerAlive(lock: string): boolean {
|
|
358
|
+
const pid = Number(lock.split(" ")[0]);
|
|
359
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
360
|
+
try {
|
|
361
|
+
process.kill(pid, 0);
|
|
362
|
+
return true;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The newest events within 1,000, none older than 7 days; lines that aren't JSON go. */
|
|
369
|
+
function bounded(lines: readonly string[], now: number): string[] {
|
|
370
|
+
const fresh = lines.filter((line) => {
|
|
371
|
+
try {
|
|
372
|
+
const at = Date.parse((JSON.parse(line) as { at?: string }).at ?? "");
|
|
373
|
+
return Number.isFinite(at) && now - at <= QUEUE_MAX_AGE_MS;
|
|
374
|
+
} catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
return fresh.slice(-QUEUE_MAX_EVENTS);
|
|
379
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this machine's time zone asks first (D140 items 2 and 10). The zone is read here, on
|
|
3
|
+
* this machine, and never sent. Every candidate name counts, as given: `TZ` (a leading `:`
|
|
4
|
+
* removed), the target of `/etc/localtime`, and `Intl`'s zone. A machine asks first when any of
|
|
5
|
+
* them is an ask-first zone, or none is a zone tzdata knows (missing, empty, `Etc/Unknown`, a
|
|
6
|
+
* POSIX rule, a typo). Names are never canonicalized: tzdata links `Europe/Zagreb` to
|
|
7
|
+
* `Europe/Belgrade` and `Indian/Reunion` to `Asia/Dubai`, and ICU builds differ in what they
|
|
8
|
+
* resolve.
|
|
9
|
+
*/
|
|
10
|
+
import { readlinkSync } from "node:fs";
|
|
11
|
+
import type { Environment } from "../home.ts";
|
|
12
|
+
import { ASK_FIRST_ZONES, KNOWN_ZONES } from "./zones.generated.ts";
|
|
13
|
+
|
|
14
|
+
const ASK_FIRST = new Set(ASK_FIRST_ZONES);
|
|
15
|
+
const KNOWN = new Set(KNOWN_ZONES);
|
|
16
|
+
|
|
17
|
+
/** Where the zone names come from; tests replace each. */
|
|
18
|
+
export interface ZoneSources {
|
|
19
|
+
readonly env: Environment;
|
|
20
|
+
/** The target of `/etc/localtime`, or `undefined`. */
|
|
21
|
+
readonly localtime?: () => string | undefined;
|
|
22
|
+
/** `Intl.DateTimeFormat().resolvedOptions().timeZone`, which can be `undefined`. */
|
|
23
|
+
readonly intl?: () => string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The target of `/etc/localtime` after `zoneinfo/`, when it is a symbolic link. */
|
|
27
|
+
function localtimeLink(): string | undefined {
|
|
28
|
+
try {
|
|
29
|
+
const target = readlinkSync("/etc/localtime");
|
|
30
|
+
const at = target.lastIndexOf("zoneinfo/");
|
|
31
|
+
return at === -1 ? undefined : target.slice(at + "zoneinfo/".length);
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function intlZone(): string | undefined {
|
|
38
|
+
try {
|
|
39
|
+
const zone: unknown = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
40
|
+
return typeof zone === "string" ? zone : undefined;
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The candidate zone names, as given (empty and missing ones dropped). */
|
|
47
|
+
export function zoneCandidates(sources: ZoneSources): string[] {
|
|
48
|
+
const tz = sources.env["TZ"];
|
|
49
|
+
const fromTz = tz === undefined ? undefined : tz.startsWith(":") ? tz.slice(1) : tz;
|
|
50
|
+
// Tests of the real binary name the machine's zone (`/etc/localtime`) with
|
|
51
|
+
// MOSTRA_TEST_LOCALTIME, empty for none, as the runtime's MOSTRA_TEST_* hooks do; nothing else
|
|
52
|
+
// reads it.
|
|
53
|
+
const testLocaltime = sources.env["MOSTRA_TEST_LOCALTIME"];
|
|
54
|
+
const localtime =
|
|
55
|
+
sources.localtime ??
|
|
56
|
+
(testLocaltime !== undefined
|
|
57
|
+
? () => (testLocaltime === "" ? undefined : testLocaltime)
|
|
58
|
+
: localtimeLink);
|
|
59
|
+
const names = [fromTz, localtime(), (sources.intl ?? intlZone)()];
|
|
60
|
+
return names.filter((name): name is string => typeof name === "string" && name.length > 0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether telemetry asks first on this machine. */
|
|
64
|
+
export function asksFirst(sources: ZoneSources): boolean {
|
|
65
|
+
const candidates = zoneCandidates(sources);
|
|
66
|
+
if (candidates.some((name) => ASK_FIRST.has(name))) return true;
|
|
67
|
+
// TZ, when set, is the zone the process runs in: an empty one (UTC under POSIX), a POSIX rule
|
|
68
|
+
// or a name tzdata doesn't know is an unknown zone, so ask first.
|
|
69
|
+
const tz = sources.env["TZ"];
|
|
70
|
+
if (tz !== undefined && !KNOWN.has(tz.startsWith(":") ? tz.slice(1) : tz)) return true;
|
|
71
|
+
return !candidates.some((name) => KNOWN.has(name) && name !== "Etc/Unknown");
|
|
72
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry's words to people (D140 item 11): the notice, the question, its answers and the
|
|
3
|
+
* `status` lines, exactly as Mirza approved them (M28). `<docs>` is `DEFAULT_DOCS_BASE` (D37).
|
|
4
|
+
*/
|
|
5
|
+
import { DEFAULT_DOCS_BASE } from "@mostra/protocol";
|
|
6
|
+
|
|
7
|
+
const DOCS = `${DEFAULT_DOCS_BASE}/telemetry`;
|
|
8
|
+
|
|
9
|
+
/** The notice where telemetry is on by default: stderr, once, before the first event. */
|
|
10
|
+
export const NOTICE_DEFAULT: string = `Mostra sends anonymous usage data to improve the CLI: the command, its
|
|
11
|
+
outcome, the version, the platform, which agent ran it, and a random
|
|
12
|
+
install ID. Never content, paths, tokens or personal data.
|
|
13
|
+
Details: ${DOCS}
|
|
14
|
+
Turn it off: mostra telemetry off, or set DO_NOT_TRACK=1.
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
/** The question in the ask-first zones, when it may be asked. */
|
|
18
|
+
export const QUESTION: string = `Mostra can send anonymous usage data to improve the CLI: the command,
|
|
19
|
+
its outcome, the version, the platform, which agent ran it, and a random
|
|
20
|
+
install ID. Never content, paths, tokens or personal data.
|
|
21
|
+
Details: ${DOCS}
|
|
22
|
+
Send anonymous usage data? [y/N] `;
|
|
23
|
+
|
|
24
|
+
/** The answers to the question. */
|
|
25
|
+
export const ANSWER = {
|
|
26
|
+
yes: "Telemetry is on. Turn it off any time: mostra telemetry off\n",
|
|
27
|
+
no: "Telemetry stays off. Turn it on any time: mostra telemetry on\n",
|
|
28
|
+
timeout: "No answer, so telemetry stays off. Turn it on any time: mostra telemetry on\n",
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
/** The notice in the ask-first zones when the question can't be asked: stderr, once. */
|
|
32
|
+
export const NOTICE_ASK_FIRST: string = `Mostra can send anonymous usage data to improve the CLI: the command,
|
|
33
|
+
its outcome, the version, the platform, which agent ran it, and a random
|
|
34
|
+
install ID. Never content, paths, tokens or personal data.
|
|
35
|
+
Details: ${DOCS}
|
|
36
|
+
Nothing is sent unless you run: mostra telemetry on
|
|
37
|
+
An agent should ask its user before turning it on.
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
/** `mostra telemetry on` and `off`. */
|
|
41
|
+
export const TURNED = {
|
|
42
|
+
on: "Telemetry is on. Turn it off any time: mostra telemetry off",
|
|
43
|
+
off: "Telemetry is off. Your install ID and any unsent events were deleted.",
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
/** Why telemetry is on or off: the decision order's reasons (D140 item 3). */
|
|
47
|
+
export type TelemetryReason =
|
|
48
|
+
| "no-collector"
|
|
49
|
+
| "embedded"
|
|
50
|
+
| "do-not-track"
|
|
51
|
+
| "variable"
|
|
52
|
+
| "variable-invalid"
|
|
53
|
+
| "setting"
|
|
54
|
+
| "default"
|
|
55
|
+
| "unreadable";
|
|
56
|
+
|
|
57
|
+
/** The first line of `mostra telemetry status`. */
|
|
58
|
+
export function statusLine(input: {
|
|
59
|
+
readonly enabled: boolean;
|
|
60
|
+
readonly reason: TelemetryReason;
|
|
61
|
+
readonly askFirst: boolean;
|
|
62
|
+
readonly settingsFile: string;
|
|
63
|
+
}): string {
|
|
64
|
+
if (input.enabled) {
|
|
65
|
+
if (input.reason === "setting") return "Telemetry is on: you turned it on.";
|
|
66
|
+
if (input.reason === "variable") return "Telemetry is on: MOSTRA_TELEMETRY=1 is set.";
|
|
67
|
+
return "Telemetry is on by default here. Turn it off: mostra telemetry off";
|
|
68
|
+
}
|
|
69
|
+
switch (input.reason) {
|
|
70
|
+
case "setting":
|
|
71
|
+
return "Telemetry is off: you turned it off.";
|
|
72
|
+
case "do-not-track":
|
|
73
|
+
return "Telemetry is off: DO_NOT_TRACK is set.";
|
|
74
|
+
case "variable":
|
|
75
|
+
return "Telemetry is off: MOSTRA_TELEMETRY=0 is set.";
|
|
76
|
+
case "variable-invalid":
|
|
77
|
+
return "Telemetry is off: MOSTRA_TELEMETRY has a value Mostra doesn't recognize, so nothing is sent.";
|
|
78
|
+
case "embedded":
|
|
79
|
+
return "Telemetry is off: a host configures this home, and hosts never send telemetry.";
|
|
80
|
+
case "no-collector":
|
|
81
|
+
return "Telemetry is off: this build has no telemetry endpoint.";
|
|
82
|
+
case "unreadable":
|
|
83
|
+
return `Telemetry is off: ${input.settingsFile} can't be read, so nothing is sent.`;
|
|
84
|
+
case "default":
|
|
85
|
+
return "Telemetry is off until you turn it on: mostra telemetry on";
|
|
86
|
+
}
|
|
87
|
+
}
|