typefully-cli 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/.github/workflows/ci.yml +7 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/CHANGELOG.md +7 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/PKG-INFO +9 -1
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/README.md +8 -0
- typefully_cli-0.4.0/collector/DEPLOYMENT.md +54 -0
- typefully_cli-0.4.0/collector/README.md +70 -0
- typefully_cli-0.4.0/collector/schema.sql +7 -0
- typefully_cli-0.4.0/collector/worker.mjs +72 -0
- typefully_cli-0.4.0/collector/worker.test.mjs +123 -0
- typefully_cli-0.4.0/collector/wrangler.example.jsonc +15 -0
- typefully_cli-0.4.0/docs/release-0.4.0.md +37 -0
- typefully_cli-0.4.0/docs/telemetry.md +65 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/pyproject.toml +1 -1
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/skills/typefully-cli/SKILL.md +12 -2
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/__init__.py +1 -1
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/cli.py +7 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/config.py +11 -2
- typefully_cli-0.4.0/src/typefully_cli/telemetry.py +85 -0
- typefully_cli-0.4.0/tests/test_telemetry.py +209 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/.github/dependabot.yml +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/.github/workflows/monitor.yml +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/.github/workflows/publish.yml +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/.gitignore +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/CLAUDE.md +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/CONTRIBUTING.md +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/Formula/README.md +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/Formula/typefully-cli.rb +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/LICENSE +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/TODOS.md +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/docs/batch-format.md +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/docs/quote-parity.md +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/docs/upstream-parity.md +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/requirements.lock +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/scripts/monitor.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/__main__.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/auth.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/batch.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/client.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/completions.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/console.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/exceptions.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/media.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/output.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/payloads.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/request-schemas.json +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/__init__.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/conftest.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_auth.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_batch.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_cli.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_client.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_completions.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_media.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_monitor.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_output.py +0 -0
- {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_payloads.py +0 -0
|
@@ -29,3 +29,10 @@ jobs:
|
|
|
29
29
|
|
|
30
30
|
- name: Test
|
|
31
31
|
run: python -m pytest tests/ -v
|
|
32
|
+
|
|
33
|
+
collector:
|
|
34
|
+
runs-on: ubuntu-22.04
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
|
37
|
+
- name: Test collector (mocked storage, no network)
|
|
38
|
+
run: node --test collector/worker.test.mjs
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
- Add minimal default-on usage telemetry for interactive text commands only, after a visible disclosure. The first notice invocation sends nothing.
|
|
6
|
+
- Disable with `TYPEFULLY_TELEMETRY=0` or `typefully config set telemetry_enabled false`. CI, noninteractive, JSON and quiet runs are excluded.
|
|
7
|
+
- Send only CLI version and command category to a maintainer-owned collector; store daily aggregate counts, not unique-user IDs or content. Network infrastructure still sees source IP.
|
|
8
|
+
- Add strict collector validation, privacy documentation and mocked telemetry tests. See `docs/telemetry.md` for retention and limits.
|
|
9
|
+
|
|
3
10
|
## 0.3.0 (2026-10-29)
|
|
4
11
|
|
|
5
12
|
- Add schema-backed `draft-payload` request-file input with offline validation, per-post fields and null/false clearing; keep `--json` as output-only.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: typefully-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: CLI for the Typefully API -- manage drafts, threads, and publishing from the terminal
|
|
5
5
|
Project-URL: Homepage, https://github.com/dheeraj-shah/typefully-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/dheeraj-shah/typefully-cli
|
|
@@ -331,6 +331,14 @@ account = "MyBrand"
|
|
|
331
331
|
output_format = "text" # or "json" for agent/script use
|
|
332
332
|
```
|
|
333
333
|
|
|
334
|
+
## Usage telemetry
|
|
335
|
+
|
|
336
|
+
This unreleased branch counts CLI version and command category only through a
|
|
337
|
+
maintainer-owned collector, after a visible notice. The first notice invocation
|
|
338
|
+
sends nothing. Published 0.3.0 installations are unchanged until a new release.
|
|
339
|
+
Disable with `TYPEFULLY_TELEMETRY=0` or
|
|
340
|
+
`typefully config set telemetry_enabled false`. See [privacy and activation details](docs/telemetry.md).
|
|
341
|
+
|
|
334
342
|
## Contributing
|
|
335
343
|
|
|
336
344
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -296,6 +296,14 @@ account = "MyBrand"
|
|
|
296
296
|
output_format = "text" # or "json" for agent/script use
|
|
297
297
|
```
|
|
298
298
|
|
|
299
|
+
## Usage telemetry
|
|
300
|
+
|
|
301
|
+
This unreleased branch counts CLI version and command category only through a
|
|
302
|
+
maintainer-owned collector, after a visible notice. The first notice invocation
|
|
303
|
+
sends nothing. Published 0.3.0 installations are unchanged until a new release.
|
|
304
|
+
Disable with `TYPEFULLY_TELEMETRY=0` or
|
|
305
|
+
`typefully config set telemetry_enabled false`. See [privacy and activation details](docs/telemetry.md).
|
|
306
|
+
|
|
299
307
|
## Contributing
|
|
300
308
|
|
|
301
309
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Deployment verification — 2026-10-29
|
|
2
|
+
|
|
3
|
+
Owner approved dedicated personal Cloudflare Worker + D1 creation on the confirmed
|
|
4
|
+
Workers/D1 Free plan; no plan change, credentials, or security grants were made.
|
|
5
|
+
|
|
6
|
+
- Endpoint: `https://typefully-cli-telemetry.dheeraj-personal-site.workers.dev/v1/count`
|
|
7
|
+
- Worker: `typefully-cli-telemetry`
|
|
8
|
+
- Account: `22980d1f74f2694e0642ea3cf9f384d9` (Dheeraj Shah)
|
|
9
|
+
- D1: `typefully-cli-telemetry`, ID `1c795c09-fb39-41bf-bf9d-a4f7deae6c9c`
|
|
10
|
+
- Worker version: `e251b178-c17f-44d1-99e5-ca1006ad1067`
|
|
11
|
+
- D1 read replication: disabled
|
|
12
|
+
|
|
13
|
+
Uploaded `worker.mjs` with only its default fetch export. The API upload response
|
|
14
|
+
confirmed `observability.enabled=false`, log/invocation-log/trace enabled flags
|
|
15
|
+
false, both log/trace persistence flags false, `logpush=false`, and empty tail
|
|
16
|
+
consumers. Subsequent settings readback confirmed the dedicated D1 binding,
|
|
17
|
+
`logpush=false` and empty tail consumers; disabled observability was omitted from
|
|
18
|
+
that read response. Account Logpush jobs list was empty. The workers.dev route was
|
|
19
|
+
read back enabled with preview URLs disabled.
|
|
20
|
+
|
|
21
|
+
Checks against the deployed endpoint used no real usage:
|
|
22
|
+
|
|
23
|
+
- GET `/v1/count` returned 405 with an empty body.
|
|
24
|
+
- POST of the rejected synthetic dimensions `synthetic-test` / `synthetic-test`
|
|
25
|
+
returned 400 with an empty body.
|
|
26
|
+
- D1 schema readback showed only `day`, `version`, `category`, `count`.
|
|
27
|
+
- D1 count after both checks: zero rows. No production-dimension count was sent.
|
|
28
|
+
- Local Node tests: 7 passed. SQLite schema/upsert checked locally.
|
|
29
|
+
|
|
30
|
+
This verifies the rejection path and deployed configuration, not a production
|
|
31
|
+
accepted-event end-to-end write. Accepted payload/database behavior is tested
|
|
32
|
+
locally with a mock D1 binding. Cloudflare infrastructure still receives IP and
|
|
33
|
+
transport metadata; application configuration and account Logpush checks do not
|
|
34
|
+
verify all provider-internal security retention. No claim of full anonymity.
|
|
35
|
+
|
|
36
|
+
The example Wrangler configuration remains intentionally inactive. An operator
|
|
37
|
+
must copy it and explicitly supply the above binding/approved route before future
|
|
38
|
+
deployment. Do not upgrade the Free plan automatically; quota exhaustion should
|
|
39
|
+
leave telemetry best-effort, not change CLI behavior or authorize charges.
|
|
40
|
+
|
|
41
|
+
## Prepared 0.4.0 candidate update — 12:20 UTC
|
|
42
|
+
|
|
43
|
+
The existing collector was redeployed with only the reviewed version allowlist
|
|
44
|
+
expanded from `0.3.0` to `0.3.0` and `0.4.0`; the CLI candidate is not published.
|
|
45
|
+
Downloaded deployed source confirmed both exact versions. Local mock tests accept
|
|
46
|
+
both and reject `0.4.0-rc.1`, `0.4.1`, and `0.5.0`.
|
|
47
|
+
|
|
48
|
+
Upload again confirmed all observability/logging/tracing and persistence flags
|
|
49
|
+
false, Logpush false, and no tail consumers. Settings readback confirmed the same
|
|
50
|
+
D1 binding and no Logpush/tails; account Logpush jobs remain empty. Route remains
|
|
51
|
+
enabled and previews disabled. A rejected synthetic POST returned 400 with zero
|
|
52
|
+
response bytes; a subsequent D1 count confirmed zero rows. No accepted production
|
|
53
|
+
payload was sent. No resources, paid plan
|
|
54
|
+
changes, credentials, or grants were added by this update.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Minimal usage collector
|
|
2
|
+
|
|
3
|
+
Owned Cloudflare Worker + dedicated D1 database. The owner approved the dedicated
|
|
4
|
+
Free-plan deployment on 2026-10-29. See [deployment verification](DEPLOYMENT.md) for
|
|
5
|
+
the exact recipient and checks. No credentials or deployment automation are bundled.
|
|
6
|
+
CLI publication/release remains a separate approval.
|
|
7
|
+
|
|
8
|
+
## Data contract
|
|
9
|
+
|
|
10
|
+
`POST /v1/count`, `Content-Type: application/json`, at most 256 UTF-8 bytes:
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{"version":"0.3.0","category":"drafts"}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Only reviewed release versions are accepted (`VERSIONS`: `0.3.0` and the prepared
|
|
17
|
+
`0.4.0` candidate); add a version explicitly
|
|
18
|
+
when releasing. Categories: `account`, `drafts`, `publishing`, `media`, `tags`,
|
|
19
|
+
`analytics`, `comments`, `queue`. Other keys/values and URLs with query strings are
|
|
20
|
+
rejected. The application ignores all request headers except content type.
|
|
21
|
+
|
|
22
|
+
The only database columns are UTC day (generated by the server), release version,
|
|
23
|
+
category, and aggregate invocation count. An atomic upsert increments that day's
|
|
24
|
+
count; there are no per-invocation rows, identifiers, fingerprints, IP addresses,
|
|
25
|
+
user agents, content, tokens, arguments, or errors stored by the application.
|
|
26
|
+
Counts measure activity, **not unique people**, and can be inflated by anyone
|
|
27
|
+
sending valid requests: this is deliberately an unauthenticated, non-identifying
|
|
28
|
+
counter, not trustworthy billing or security data.
|
|
29
|
+
|
|
30
|
+
On each accepted request the same D1 transaction removes aggregate rows older
|
|
31
|
+
than 90 days. If traffic stops, old aggregate rows remain until another accepted
|
|
32
|
+
request or an operator deletes them. D1 backups may temporarily retain deleted
|
|
33
|
+
aggregate rows. No application request logs, traces, or Logpush are enabled in the
|
|
34
|
+
template; do not enable tail consumers or add request logging.
|
|
35
|
+
|
|
36
|
+
Cloudflare necessarily receives source IP and other transport metadata. This code
|
|
37
|
+
cannot promise that Cloudflare's independent infrastructure/security processing
|
|
38
|
+
retains none of it. Review the service's current policies and account settings
|
|
39
|
+
before enabling collection; do not describe it as fully anonymous.
|
|
40
|
+
|
|
41
|
+
## Local verification (no HTTP traffic)
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
node --test collector/worker.test.mjs
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Tests use in-memory Requests and a fake D1 binding; they never call a live endpoint.
|
|
48
|
+
|
|
49
|
+
## Deployment approval checklist
|
|
50
|
+
|
|
51
|
+
1. Approve the exact owner/account, endpoint and disclosed recipient. Verify the
|
|
52
|
+
account's current Workers/D1 billing plan, quotas and limits; do not assume zero
|
|
53
|
+
cost from the word "free". Do not upgrade a plan or grant new credentials.
|
|
54
|
+
2. Approve creation of a dedicated Worker and dedicated D1 database. Do not reuse a
|
|
55
|
+
newsletter/customer database or Worker with request logging enabled.
|
|
56
|
+
3. Copy `wrangler.example.jsonc` to a private deployment configuration, add the
|
|
57
|
+
approved D1 binding named `COUNTS`, and apply `schema.sql` to that database.
|
|
58
|
+
4. Verify observability logs/traces, invocation logs, Logpush and tail consumers are
|
|
59
|
+
disabled in effective deployed settings, including any account-level exports.
|
|
60
|
+
Review platform retention separately. Enable only the approved HTTPS route.
|
|
61
|
+
5. Update the CLI compiled endpoint and its disclosure together after approval.
|
|
62
|
+
Run mocked tests and review the diff. Publishing/releasing remains a separate
|
|
63
|
+
approval; deployment is not permission to release the CLI.
|
|
64
|
+
6. Do not perform a real telemetry smoke test without approval for that payload and
|
|
65
|
+
recipient. A health check must not record an event.
|
|
66
|
+
|
|
67
|
+
Current API references used when preparing this proposal:
|
|
68
|
+
[Workers best practices](https://developers.cloudflare.com/workers/best-practices/workers-best-practices/),
|
|
69
|
+
[D1 prepared statements](https://developers.cloudflare.com/d1/worker-api/prepared-statements/),
|
|
70
|
+
[Wrangler configuration](https://developers.cloudflare.com/workers/wrangler/configuration/).
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// This collector deliberately never records request headers, IPs, or event-level data.
|
|
2
|
+
const VERSIONS = new Set(['0.3.0', '0.4.0']);
|
|
3
|
+
const CATEGORIES = new Set(['account', 'drafts', 'publishing', 'media', 'tags', 'analytics', 'comments', 'queue']);
|
|
4
|
+
const MAX_BYTES = 256;
|
|
5
|
+
|
|
6
|
+
async function readPayload(request) {
|
|
7
|
+
if (!request.body) throw new Error('invalid');
|
|
8
|
+
const reader = request.body.getReader();
|
|
9
|
+
const chunks = [];
|
|
10
|
+
let size = 0;
|
|
11
|
+
try {
|
|
12
|
+
while (true) {
|
|
13
|
+
const { done, value } = await reader.read();
|
|
14
|
+
if (done) break;
|
|
15
|
+
size += value.byteLength;
|
|
16
|
+
if (size > MAX_BYTES) {
|
|
17
|
+
await reader.cancel();
|
|
18
|
+
throw new Error('invalid');
|
|
19
|
+
}
|
|
20
|
+
chunks.push(value);
|
|
21
|
+
}
|
|
22
|
+
} finally {
|
|
23
|
+
reader.releaseLock();
|
|
24
|
+
}
|
|
25
|
+
const bytes = new Uint8Array(size);
|
|
26
|
+
let offset = 0;
|
|
27
|
+
for (const chunk of chunks) {
|
|
28
|
+
bytes.set(chunk, offset);
|
|
29
|
+
offset += chunk.byteLength;
|
|
30
|
+
}
|
|
31
|
+
const data = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
32
|
+
if (!data || Array.isArray(data) || typeof data !== 'object'
|
|
33
|
+
|| Object.keys(data).sort().join(',') !== 'category,version'
|
|
34
|
+
|| !VERSIONS.has(data.version) || !CATEGORIES.has(data.category)) {
|
|
35
|
+
throw new Error('invalid');
|
|
36
|
+
}
|
|
37
|
+
return data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const response = (status) => new Response(null, {
|
|
41
|
+
status, headers: { 'Cache-Control': 'no-store' },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
async fetch(request, env) {
|
|
46
|
+
const url = new URL(request.url);
|
|
47
|
+
if (url.pathname !== '/v1/count' || url.search) return response(404);
|
|
48
|
+
if (request.method !== 'POST') return response(405);
|
|
49
|
+
if (request.headers.get('content-type') !== 'application/json') return response(415);
|
|
50
|
+
let data;
|
|
51
|
+
try {
|
|
52
|
+
data = await readPayload(request);
|
|
53
|
+
} catch {
|
|
54
|
+
return response(400);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
// Only UTC day, approved release version/category, and an aggregate count reach D1.
|
|
58
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
59
|
+
const cutoff = new Date(Date.now() - 90 * 86400000).toISOString().slice(0, 10);
|
|
60
|
+
await env.COUNTS.batch([
|
|
61
|
+
env.COUNTS.prepare('DELETE FROM daily_counts WHERE day < ?').bind(cutoff),
|
|
62
|
+
env.COUNTS.prepare(`INSERT INTO daily_counts (day, version, category, count)
|
|
63
|
+
VALUES (?, ?, ?, 1)
|
|
64
|
+
ON CONFLICT(day, version, category) DO UPDATE SET count = count + 1`)
|
|
65
|
+
.bind(day, data.version, data.category),
|
|
66
|
+
]);
|
|
67
|
+
return response(204);
|
|
68
|
+
} catch {
|
|
69
|
+
return response(503);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import worker from './worker.mjs';
|
|
4
|
+
const CATEGORIES = ['account', 'drafts', 'publishing', 'media', 'tags', 'analytics', 'comments', 'queue'];
|
|
5
|
+
|
|
6
|
+
function database(fail = false) {
|
|
7
|
+
const batches = [];
|
|
8
|
+
return {
|
|
9
|
+
batches,
|
|
10
|
+
prepare(sql) { return { bind: (...values) => ({ sql, values }) }; },
|
|
11
|
+
async batch(statements) {
|
|
12
|
+
if (fail) throw new Error('private backend details');
|
|
13
|
+
batches.push(statements);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function request(body = { version: '0.3.0', category: 'drafts' }, options = {}) {
|
|
18
|
+
return new Request('https://collector.invalid/v1/count', {
|
|
19
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
20
|
+
body: typeof body === 'string' ? body : JSON.stringify(body), ...options,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('every category stores only daily aggregate dimensions; never headers', async () => {
|
|
25
|
+
for (const category of CATEGORIES) {
|
|
26
|
+
const db = database();
|
|
27
|
+
const req = request({ version: '0.3.0', category });
|
|
28
|
+
req.headers.set('CF-Connecting-IP', '192.0.2.1');
|
|
29
|
+
req.headers.set('User-Agent', 'private-machine-name');
|
|
30
|
+
req.headers.set('Authorization', 'Bearer secret');
|
|
31
|
+
const res = await worker.fetch(req, { COUNTS: db });
|
|
32
|
+
assert.equal(res.status, 204);
|
|
33
|
+
assert.equal(res.headers.get('Cache-Control'), 'no-store');
|
|
34
|
+
assert.equal(db.batches.length, 1);
|
|
35
|
+
const [cleanup, increment] = db.batches[0];
|
|
36
|
+
assert.match(cleanup.sql, /DELETE FROM daily_counts/);
|
|
37
|
+
assert.match(cleanup.values[0], /^\d{4}-\d{2}-\d{2}$/);
|
|
38
|
+
assert.match(increment.sql, /ON CONFLICT.*DO UPDATE SET count = count \+ 1/);
|
|
39
|
+
assert.deepEqual(increment.values, [new Date().toISOString().slice(0, 10), '0.3.0', category]);
|
|
40
|
+
assert.doesNotMatch(JSON.stringify(db.batches), /secret|private-machine-name|192\.0/);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('accepts both reviewed release versions without broadening the allowlist', async () => {
|
|
45
|
+
for (const version of ['0.3.0', '0.4.0']) {
|
|
46
|
+
const db = database();
|
|
47
|
+
assert.equal((await worker.fetch(request({ version, category: 'drafts' }), { COUNTS: db })).status, 204);
|
|
48
|
+
assert.equal(db.batches[0][1].values[1], version);
|
|
49
|
+
}
|
|
50
|
+
for (const version of ['0.4.0-rc.1', '0.4.1', '0.5.0']) {
|
|
51
|
+
const db = database();
|
|
52
|
+
assert.equal((await worker.fetch(request({ version, category: 'drafts' }), { COUNTS: db })).status, 400);
|
|
53
|
+
assert.deepEqual(db.batches, []);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('rejects malformed, unapproved, and extra fields before storage', async () => {
|
|
58
|
+
for (const body of [
|
|
59
|
+
'not json', 'null', '[]', '"text"', '{}',
|
|
60
|
+
{ version: '0.3.0', category: 'drafts', content: 'secret' },
|
|
61
|
+
{ version: '0.3.0', category: 'unknown' },
|
|
62
|
+
{ version: 'my-private-build', category: 'drafts' },
|
|
63
|
+
{ version: '99.99.99', category: 'drafts' },
|
|
64
|
+
{ version: 3, category: 'drafts' },
|
|
65
|
+
{ version: '0.3.0', category: ['drafts'] },
|
|
66
|
+
' '.repeat(257),
|
|
67
|
+
]) {
|
|
68
|
+
const db = database();
|
|
69
|
+
const res = await worker.fetch(request(body), { COUNTS: db });
|
|
70
|
+
assert.equal(res.status, 400, JSON.stringify(body));
|
|
71
|
+
assert.deepEqual(db.batches, []);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('bounded streamed body ignores forged Content-Length', async () => {
|
|
76
|
+
const stream = new ReadableStream({
|
|
77
|
+
start(controller) {
|
|
78
|
+
controller.enqueue(new TextEncoder().encode(' '.repeat(200)));
|
|
79
|
+
controller.enqueue(new TextEncoder().encode(' '.repeat(200)));
|
|
80
|
+
controller.close();
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const db = database();
|
|
84
|
+
const req = request('', { body: stream, duplex: 'half', headers: {
|
|
85
|
+
'Content-Type': 'application/json', 'Content-Length': '1',
|
|
86
|
+
} });
|
|
87
|
+
assert.equal((await worker.fetch(req, { COUNTS: db })).status, 400);
|
|
88
|
+
assert.deepEqual(db.batches, []);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('rejects invalid UTF-8, route, query, method and content type', async () => {
|
|
92
|
+
const db = database();
|
|
93
|
+
for (const [req, status] of [
|
|
94
|
+
[request('', { body: new Uint8Array([0xff]) }), 400],
|
|
95
|
+
[new Request('https://collector.invalid/'), 404],
|
|
96
|
+
[new Request('https://collector.invalid/v1/count?secret=1'), 404],
|
|
97
|
+
[new Request('https://collector.invalid/v1/count'), 405],
|
|
98
|
+
[request('{}', { headers: { 'Content-Type': 'text/plain' } }), 415],
|
|
99
|
+
]) assert.equal((await worker.fetch(req, { COUNTS: db })).status, status);
|
|
100
|
+
assert.deepEqual(db.batches, []);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('storage errors do not expose backend details or fail open', async () => {
|
|
104
|
+
const res = await worker.fetch(request(), { COUNTS: database(true) });
|
|
105
|
+
assert.equal(res.status, 503);
|
|
106
|
+
assert.equal(await res.text(), '');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('deployment template keeps public routes and application logging disabled', async () => {
|
|
110
|
+
const { readFile } = await import('node:fs/promises');
|
|
111
|
+
const template = await readFile(new URL('./wrangler.example.jsonc', import.meta.url), 'utf8');
|
|
112
|
+
const cfg = JSON.parse(template.replace(/\/\/.*$/gm, ''));
|
|
113
|
+
assert.equal(cfg.workers_dev, false);
|
|
114
|
+
assert.equal(cfg.preview_urls, false);
|
|
115
|
+
assert.equal(cfg.logpush, false);
|
|
116
|
+
assert.equal(cfg.observability.enabled, false);
|
|
117
|
+
assert.equal(cfg.observability.logs.enabled, false);
|
|
118
|
+
assert.equal(cfg.observability.logs.invocation_logs, false);
|
|
119
|
+
assert.equal(cfg.observability.traces.enabled, false);
|
|
120
|
+
assert.equal(cfg.routes, undefined);
|
|
121
|
+
assert.equal(cfg.account_id, undefined);
|
|
122
|
+
assert.equal(cfg.d1_databases, undefined);
|
|
123
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "typefully-cli-telemetry",
|
|
3
|
+
"main": "worker.mjs",
|
|
4
|
+
"compatibility_date": "2026-10-29",
|
|
5
|
+
"workers_dev": false,
|
|
6
|
+
"preview_urls": false,
|
|
7
|
+
"logpush": false,
|
|
8
|
+
"observability": {
|
|
9
|
+
"enabled": false,
|
|
10
|
+
"logs": { "enabled": false, "invocation_logs": false },
|
|
11
|
+
"traces": { "enabled": false }
|
|
12
|
+
}
|
|
13
|
+
// After explicit deployment approval, add a dedicated D1 binding named COUNTS,
|
|
14
|
+
// with the approved database_name/database_id, and enable the approved route.
|
|
15
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# 0.4.0 release candidate
|
|
2
|
+
|
|
3
|
+
Prepared, not published. A minor version reflects the new default-on telemetry
|
|
4
|
+
feature. On 2026-10-29 PyPI reported latest version 0.3.0 and no 0.4.0; the remote
|
|
5
|
+
Git tags had no v0.4.0. Recheck immediately before publication.
|
|
6
|
+
|
|
7
|
+
## Release contract
|
|
8
|
+
|
|
9
|
+
- Package metadata and runtime version: 0.4.0.
|
|
10
|
+
- Collector explicitly accepts 0.4.0 alongside the previous reviewed 0.3.0.
|
|
11
|
+
- First eligible interactive text invocation displays disclosure and sends nothing.
|
|
12
|
+
- Later eligible invocations send only version/category. Opt out with
|
|
13
|
+
`TYPEFULLY_TELEMETRY=0` or `typefully config set telemetry_enabled false`.
|
|
14
|
+
- CI, noninteractive, JSON and quiet invocations do not send.
|
|
15
|
+
- Skill and privacy documentation describe these controls and infrastructure IP visibility.
|
|
16
|
+
|
|
17
|
+
## Final checks
|
|
18
|
+
|
|
19
|
+
Run the complete Python suite, CI Ruff selection, Node collector tests and
|
|
20
|
+
`git diff --check`. Build both sdist and wheel with `python -m build` and validate
|
|
21
|
+
metadata using `python -m twine check dist/typefully_cli-0.4.0*`. Install the wheel
|
|
22
|
+
in a clean environment and check version, help, offline payload validation and
|
|
23
|
+
opt-out persistence with networking blocked. Verify artifact versions, schema and
|
|
24
|
+
telemetry source; ensure the source distribution includes skill/privacy documents.
|
|
25
|
+
All telemetry tests use mocks; do not send real usage as a release check.
|
|
26
|
+
|
|
27
|
+
## Publication remains gated
|
|
28
|
+
|
|
29
|
+
Do not merge, create/push `v0.4.0`, publish to PyPI, create a GitHub release, or change
|
|
30
|
+
Homebrew until the owner approves those exact destinations. Pushing a `v*` tag
|
|
31
|
+
triggers the existing trusted PyPI publishing workflow. A PR push does not.
|
|
32
|
+
Homebrew is maintained separately; coordinate after the release is available,
|
|
33
|
+
without touching its current 0.3.0 work.
|
|
34
|
+
|
|
35
|
+
Suggested approval question: **May I merge PR #16 and publish typefully-cli 0.4.0
|
|
36
|
+
by pushing tag v0.4.0 (which triggers PyPI), then create the matching GitHub release?
|
|
37
|
+
Homebrew updates would be handled separately.**
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Usage telemetry
|
|
2
|
+
|
|
3
|
+
## Current status
|
|
4
|
+
|
|
5
|
+
The maintainer-owned collector is deployed at
|
|
6
|
+
`https://typefully-cli-telemetry.dheeraj-personal-site.workers.dev/v1/count`.
|
|
7
|
+
This unreleased branch is configured to use it, subject to the notice and opt-out
|
|
8
|
+
rules below. Existing published 0.3.0 installations are unchanged. A package
|
|
9
|
+
release still requires separate approval. There is no third-party analytics SDK.
|
|
10
|
+
See [deployment verification](../collector/DEPLOYMENT.md).
|
|
11
|
+
|
|
12
|
+
## What an activated build measures
|
|
13
|
+
|
|
14
|
+
A request contains only the released CLI version and an allowlisted command
|
|
15
|
+
category: account, drafts, publishing, media, tags, analytics, comments, or queue.
|
|
16
|
+
It does not contain command arguments, post text, API keys, account information,
|
|
17
|
+
errors, hostnames, installation IDs, or device identifiers. It measures command
|
|
18
|
+
activity, **not unique users**, installations, or successful API operations.
|
|
19
|
+
|
|
20
|
+
The collector increments a daily aggregate count for version/category. It does
|
|
21
|
+
not store individual events or application-level IP/user-agent logs. Aggregate rows
|
|
22
|
+
older than 90 days are removed on the next accepted request; if traffic stops,
|
|
23
|
+
they remain until another request or operator cleanup. Backups may retain deleted
|
|
24
|
+
aggregates temporarily. Network and
|
|
25
|
+
hosting infrastructure necessarily receive source IPs and connection metadata;
|
|
26
|
+
this is not a promise of full anonymity or zero infrastructure retention.
|
|
27
|
+
|
|
28
|
+
## Notice and opt-out
|
|
29
|
+
|
|
30
|
+
Before any sending can begin, an interactive invocation displays a notice naming
|
|
31
|
+
the collector and opt-out controls. That invocation sends nothing. Only a later
|
|
32
|
+
eligible invocation can send. A local marker records that notice for that endpoint;
|
|
33
|
+
it is not an identifier and is never uploaded. The first notice does not rewrite
|
|
34
|
+
your API-key configuration.
|
|
35
|
+
|
|
36
|
+
Disable before running any other command:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
export TYPEFULLY_TELEMETRY=0
|
|
40
|
+
# Or persist the preference:
|
|
41
|
+
typefully config set telemetry_enabled false
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Opt-out takes precedence over the notice marker. Config and completion commands,
|
|
45
|
+
help/version, CI, noninteractive runs, and structured/quiet output do not send.
|
|
46
|
+
Telemetry failures must not fail the user's command. There is no retry queue,
|
|
47
|
+
background process, or retrospective upload of skipped commands.
|
|
48
|
+
|
|
49
|
+
## Maintainer activation checklist
|
|
50
|
+
|
|
51
|
+
1. Obtain approval for the specific collector recipient and hosting resource changes.
|
|
52
|
+
2. Verify the account's Workers/D1 plan and spending behavior; do not assume free
|
|
53
|
+
allowances imply zero possible charges on an existing paid account.
|
|
54
|
+
3. Review the collector deployment guide, disable invocation logs, traces, logpush,
|
|
55
|
+
and tail consumers, and review infrastructure-level retention separately.
|
|
56
|
+
4. Validate the collector with synthetic payloads only after testing authorization;
|
|
57
|
+
all repository tests use mocks and must not send actual CLI usage.
|
|
58
|
+
5. Configure the fixed HTTPS endpoint in source. Do not accept a user-controlled
|
|
59
|
+
endpoint or reuse the authenticated Typefully API client.
|
|
60
|
+
6. Review the disclosure against the deployed recipient, fields, and retention.
|
|
61
|
+
7. Obtain separate approval before merging or publishing a release.
|
|
62
|
+
|
|
63
|
+
Counts are approximate and can be inflated by clients or bots. Do not add IP-based
|
|
64
|
+
fingerprints or persistent IDs to deduplicate them. See [collector](../collector/)
|
|
65
|
+
for the source and deployment prerequisites.
|
|
@@ -6,8 +6,8 @@ description: Use this repository's Python Typefully CLI to inspect accounts and
|
|
|
6
6
|
# Typefully CLI
|
|
7
7
|
|
|
8
8
|
Run `typefully --help` and the selected command's `--help` before assuming flags.
|
|
9
|
-
This skill describes the
|
|
10
|
-
packages may lack these commands. Do not treat repository documentation as proof
|
|
9
|
+
This skill describes the 0.4.0 release candidate; older installed
|
|
10
|
+
packages may lack these commands or telemetry controls. Do not treat repository documentation as proof
|
|
11
11
|
that the installed package has been upgraded.
|
|
12
12
|
|
|
13
13
|
## Access and output
|
|
@@ -18,6 +18,16 @@ connected sets; a configured default is not proof it is the intended destination
|
|
|
18
18
|
`--json` controls **output**, not the request body. Most commands also offer text
|
|
19
19
|
output; `draft-payload` and `media-update` always emit structured JSON.
|
|
20
20
|
|
|
21
|
+
## Usage telemetry
|
|
22
|
+
|
|
23
|
+
For automation, set `TYPEFULLY_TELEMETRY=0` explicitly. CI, noninteractive,
|
|
24
|
+
`--json` and quiet runs do not send telemetry. Interactive text runs display a
|
|
25
|
+
notice before any later sending; the notice invocation sends nothing. Disable
|
|
26
|
+
persistently with `typefully config set telemetry_enabled false`. Only version
|
|
27
|
+
and command category are sent, never post content, credentials or account IDs.
|
|
28
|
+
The maintainer-owned host sees source IP; do not call this fully anonymous.
|
|
29
|
+
See `docs/telemetry.md` for the recipient and retention details.
|
|
30
|
+
|
|
21
31
|
## Prepare and validate
|
|
22
32
|
|
|
23
33
|
- Use `draft`/`thread` for ordinary content and `update` for an existing draft.
|
|
@@ -24,6 +24,7 @@ from typefully_cli.exceptions import APIError, TypefullyError
|
|
|
24
24
|
from typefully_cli.media import upload_media
|
|
25
25
|
from typefully_cli import output as fmt
|
|
26
26
|
from typefully_cli.completions import completions
|
|
27
|
+
from typefully_cli.telemetry import record_usage
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
# --- Shared option decorator ---
|
|
@@ -42,6 +43,12 @@ def shared_options(f):
|
|
|
42
43
|
@click.option("--debug", is_flag=True, hidden=True, help="Log HTTP requests to stderr")
|
|
43
44
|
@wraps(f)
|
|
44
45
|
def wrapper(*args, **kwargs):
|
|
46
|
+
ctx = click.get_current_context(silent=True)
|
|
47
|
+
if ctx is not None:
|
|
48
|
+
record_usage(
|
|
49
|
+
ctx.find_root().invoked_subcommand,
|
|
50
|
+
quiet=kwargs.get("quiet", False) or kwargs.get("use_json", False),
|
|
51
|
+
)
|
|
45
52
|
return f(*args, **kwargs)
|
|
46
53
|
|
|
47
54
|
return wrapper
|
|
@@ -36,6 +36,7 @@ class Config:
|
|
|
36
36
|
output_format: str = "text"
|
|
37
37
|
default_platforms: str = ""
|
|
38
38
|
timezone: str = ""
|
|
39
|
+
telemetry_enabled: bool = True
|
|
39
40
|
_path: Path = field(default_factory=_config_path)
|
|
40
41
|
|
|
41
42
|
@classmethod
|
|
@@ -54,6 +55,8 @@ class Config:
|
|
|
54
55
|
cfg.output_format = defaults.get("output_format", "text")
|
|
55
56
|
cfg.default_platforms = defaults.get("platforms", "")
|
|
56
57
|
cfg.timezone = defaults.get("timezone", "")
|
|
58
|
+
# Fail closed on malformed values rather than silently enabling collection.
|
|
59
|
+
cfg.telemetry_enabled = data.get("telemetry", {}).get("enabled", True) is True
|
|
57
60
|
return cfg
|
|
58
61
|
|
|
59
62
|
def save(self) -> None:
|
|
@@ -77,6 +80,7 @@ class Config:
|
|
|
77
80
|
if self.timezone:
|
|
78
81
|
lines.append(f'timezone = "{_toml_escape(self.timezone)}"')
|
|
79
82
|
lines.append("")
|
|
83
|
+
lines.extend(["[telemetry]", f"enabled = {str(self.telemetry_enabled).lower()}", ""])
|
|
80
84
|
self._path.write_text("\n".join(lines))
|
|
81
85
|
os.chmod(self._path, 0o600)
|
|
82
86
|
|
|
@@ -86,6 +90,7 @@ class Config:
|
|
|
86
90
|
"api_key": _redact(self.api_key) if redact else self.api_key,
|
|
87
91
|
},
|
|
88
92
|
"defaults": {},
|
|
93
|
+
"telemetry": {"enabled": self.telemetry_enabled},
|
|
89
94
|
}
|
|
90
95
|
if self.onepassword_item:
|
|
91
96
|
d["auth"]["onepassword_item"] = self.onepassword_item
|
|
@@ -103,7 +108,7 @@ class Config:
|
|
|
103
108
|
|
|
104
109
|
_VALID_KEYS = {
|
|
105
110
|
"api_key", "default_account", "onepassword_item", "output_format",
|
|
106
|
-
"default_platforms", "timezone",
|
|
111
|
+
"default_platforms", "timezone", "telemetry_enabled",
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
_KNOWN_PLATFORMS = {"x", "linkedin", "threads", "bluesky", "mastodon"}
|
|
@@ -138,7 +143,11 @@ def config_set(cfg: Config, key: str, value: str) -> dict:
|
|
|
138
143
|
"""Set a config key. Returns the result dict (api_key redacted)."""
|
|
139
144
|
if key not in _VALID_KEYS:
|
|
140
145
|
raise ValueError(f"Unknown config key: {key}. Valid: {', '.join(sorted(_VALID_KEYS))}")
|
|
141
|
-
if key == "
|
|
146
|
+
if key == "telemetry_enabled":
|
|
147
|
+
if value.lower() not in {"true", "false"}:
|
|
148
|
+
raise ValueError("telemetry_enabled must be true or false")
|
|
149
|
+
cfg.telemetry_enabled = value.lower() == "true"
|
|
150
|
+
elif key == "api_key":
|
|
142
151
|
cfg.api_key = value
|
|
143
152
|
elif key == "default_account":
|
|
144
153
|
cfg.default_account = value
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Best-effort aggregate usage with a disclosed, maintainer-owned collector."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from typefully_cli import __version__
|
|
14
|
+
from typefully_cli.config import Config
|
|
15
|
+
|
|
16
|
+
# Maintainer-owned recipient; changing it requires a new visible notice.
|
|
17
|
+
ENDPOINT = "https://typefully-cli-telemetry.dheeraj-personal-site.workers.dev/v1/count"
|
|
18
|
+
NOTICE_REVISION = "1"
|
|
19
|
+
COMMAND_CATEGORIES = {
|
|
20
|
+
"me": "account", "accounts": "account", "account-detail": "account",
|
|
21
|
+
"linkedin-resolve": "account",
|
|
22
|
+
"recent": "drafts", "draft": "drafts", "thread": "drafts", "get": "drafts",
|
|
23
|
+
"open": "drafts", "update": "drafts", "delete": "drafts", "drafts": "drafts",
|
|
24
|
+
"batch": "drafts", "plan": "drafts",
|
|
25
|
+
"publish": "publishing", "schedule": "publishing",
|
|
26
|
+
"upload": "media", "media-status": "media",
|
|
27
|
+
"tags": "tags", "tag-create": "tags",
|
|
28
|
+
"analytics": "analytics", "followers": "analytics",
|
|
29
|
+
"comments": "comments", "queue": "queue", "queue-schedule": "queue",
|
|
30
|
+
}
|
|
31
|
+
CI_VARIABLES = ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "BUILD_BUILDID", "TF_BUILD")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def record_usage(command: str | None, *, quiet: bool = False) -> None:
|
|
35
|
+
"""Count a known command, never its arguments, without affecting its outcome."""
|
|
36
|
+
try:
|
|
37
|
+
category = COMMAND_CATEGORIES.get(command)
|
|
38
|
+
if not ENDPOINT or category is None or quiet:
|
|
39
|
+
return
|
|
40
|
+
if any(os.environ.get(key) for key in CI_VARIABLES):
|
|
41
|
+
return
|
|
42
|
+
if os.environ.get("TYPEFULLY_TELEMETRY", "").lower() in {"0", "false", "off", "no"}:
|
|
43
|
+
return
|
|
44
|
+
# Never collect from scripts, agents, redirected output, or background jobs.
|
|
45
|
+
if not all(stream.isatty() for stream in (sys.stdin, sys.stdout, sys.stderr)):
|
|
46
|
+
return
|
|
47
|
+
endpoint = urlsplit(ENDPOINT)
|
|
48
|
+
if endpoint.scheme != "https" or not endpoint.hostname or endpoint.username or endpoint.password:
|
|
49
|
+
return
|
|
50
|
+
if endpoint.query or endpoint.fragment or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", __version__):
|
|
51
|
+
return
|
|
52
|
+
cfg = Config.load()
|
|
53
|
+
if not cfg.telemetry_enabled or cfg.output_format == "json":
|
|
54
|
+
return
|
|
55
|
+
marker = cfg._path.parent / "telemetry-notice"
|
|
56
|
+
notice_key = NOTICE_REVISION + "\n" + ENDPOINT + "\n"
|
|
57
|
+
if not marker.is_file() or marker.read_text() != notice_key:
|
|
58
|
+
click.echo(
|
|
59
|
+
"Typefully CLI usage telemetry is enabled by default for later interactive runs.\n"
|
|
60
|
+
"Only CLI version and command category are sent to " + ENDPOINT + ".\n"
|
|
61
|
+
"No content, arguments, API keys, account details, or persistent device ID are sent.\n"
|
|
62
|
+
"The host sees your network IP; the collector stores only aggregate counts, "
|
|
63
|
+
"not IPs or user agents. This is activity measurement, not unique users.\n"
|
|
64
|
+
"Nothing is sent on this run. Before your next run, opt out with\n"
|
|
65
|
+
"TYPEFULLY_TELEMETRY=0 or: typefully config set telemetry_enabled false",
|
|
66
|
+
err=True,
|
|
67
|
+
)
|
|
68
|
+
# Keep disclosure state separate: never rewrite auth config just to record a notice.
|
|
69
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
with marker.open("w") as handle:
|
|
71
|
+
os.chmod(marker, 0o600)
|
|
72
|
+
handle.write(notice_key)
|
|
73
|
+
return
|
|
74
|
+
# Dedicated transport: no Typefully auth, proxy credentials, redirects, or retries.
|
|
75
|
+
with httpx.Client(timeout=0.2, follow_redirects=False, trust_env=False) as client:
|
|
76
|
+
client.headers.clear()
|
|
77
|
+
with client.stream(
|
|
78
|
+
"POST", ENDPOINT,
|
|
79
|
+
headers={"Content-Type": "application/json", "User-Agent": "typefully-cli"},
|
|
80
|
+
json={"version": __version__, "category": category},
|
|
81
|
+
):
|
|
82
|
+
pass # Do not consume a response body or follow server instructions.
|
|
83
|
+
except Exception:
|
|
84
|
+
# Telemetry must never fail the user's command or print sensitive transport errors.
|
|
85
|
+
return
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Telemetry is exercised exclusively with a fake HTTP client."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from click.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from typefully_cli import telemetry
|
|
11
|
+
from typefully_cli.cli import cli
|
|
12
|
+
from typefully_cli.config import Config, config_set
|
|
13
|
+
|
|
14
|
+
REAL_HTTPX_CLIENT = httpx.Client
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def setup(monkeypatch, tmp_path):
|
|
19
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
20
|
+
monkeypatch.delenv("TYPEFULLY_TELEMETRY", raising=False)
|
|
21
|
+
for key in telemetry.CI_VARIABLES:
|
|
22
|
+
monkeypatch.delenv(key, raising=False)
|
|
23
|
+
monkeypatch.setattr(telemetry, "ENDPOINT", "https://collector.invalid/usage")
|
|
24
|
+
for stream in (telemetry.sys.stdin, telemetry.sys.stdout, telemetry.sys.stderr):
|
|
25
|
+
monkeypatch.setattr(stream, "isatty", lambda: True)
|
|
26
|
+
factory = MagicMock()
|
|
27
|
+
client = factory.return_value.__enter__.return_value
|
|
28
|
+
monkeypatch.setattr(telemetry.httpx, "Client", factory)
|
|
29
|
+
return tmp_path / "typefully" / "telemetry-notice", factory, client
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_first_notice_no_send_then_exact_payload(setup, capsys, monkeypatch):
|
|
33
|
+
for stream in (telemetry.sys.stdin, telemetry.sys.stdout, telemetry.sys.stderr):
|
|
34
|
+
monkeypatch.setattr(stream, "isatty", lambda: True)
|
|
35
|
+
marker, factory, client = setup
|
|
36
|
+
telemetry.record_usage("draft")
|
|
37
|
+
factory.assert_not_called()
|
|
38
|
+
text = capsys.readouterr().err
|
|
39
|
+
assert "Nothing is sent on this run" in text
|
|
40
|
+
assert "TYPEFULLY_TELEMETRY=0" in text
|
|
41
|
+
assert "network IP" in text
|
|
42
|
+
assert marker.exists()
|
|
43
|
+
assert not (marker.parent / "config.toml").exists()
|
|
44
|
+
telemetry.record_usage("draft")
|
|
45
|
+
factory.assert_called_once_with(timeout=0.2, follow_redirects=False, trust_env=False)
|
|
46
|
+
client.headers.clear.assert_called_once()
|
|
47
|
+
client.stream.assert_called_once_with(
|
|
48
|
+
"POST", "https://collector.invalid/usage",
|
|
49
|
+
headers={"Content-Type": "application/json", "User-Agent": "typefully-cli"},
|
|
50
|
+
json={"version": "0.4.0", "category": "drafts"},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize("setting", ["0", "false", "off", "no", "FALSE"])
|
|
55
|
+
def test_environment_optout(setup, monkeypatch, setting):
|
|
56
|
+
marker, factory, _ = setup
|
|
57
|
+
monkeypatch.setenv("TYPEFULLY_TELEMETRY", setting)
|
|
58
|
+
telemetry.record_usage("draft")
|
|
59
|
+
assert not marker.exists()
|
|
60
|
+
factory.assert_not_called()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_config_optout_cannot_be_overridden_by_env(setup, monkeypatch):
|
|
64
|
+
marker, factory, _ = setup
|
|
65
|
+
cfg = Config.load()
|
|
66
|
+
config_set(cfg, "telemetry_enabled", "false")
|
|
67
|
+
monkeypatch.setenv("TYPEFULLY_TELEMETRY", "1")
|
|
68
|
+
telemetry.record_usage("draft")
|
|
69
|
+
assert Config.load().telemetry_enabled is False
|
|
70
|
+
assert not marker.exists()
|
|
71
|
+
factory.assert_not_called()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.parametrize("key", telemetry.CI_VARIABLES)
|
|
75
|
+
def test_ci_always_skipped_even_with_marker(setup, monkeypatch, key):
|
|
76
|
+
_, factory, _ = setup
|
|
77
|
+
telemetry.record_usage("draft")
|
|
78
|
+
monkeypatch.setenv(key, "true")
|
|
79
|
+
telemetry.record_usage("draft")
|
|
80
|
+
factory.assert_not_called()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.parametrize("stream", ["stdin", "stdout", "stderr"])
|
|
84
|
+
def test_noninteractive_always_skipped(setup, monkeypatch, stream):
|
|
85
|
+
_, factory, _ = setup
|
|
86
|
+
telemetry.record_usage("draft")
|
|
87
|
+
monkeypatch.setattr(getattr(telemetry.sys, stream), "isatty", lambda: False)
|
|
88
|
+
telemetry.record_usage("draft")
|
|
89
|
+
factory.assert_not_called()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.parametrize("command", [None, "config", "completions", "secret text", "help"])
|
|
93
|
+
def test_unknown_and_administrative_commands_excluded(setup, command):
|
|
94
|
+
marker, factory, _ = setup
|
|
95
|
+
telemetry.record_usage(command)
|
|
96
|
+
assert not marker.exists()
|
|
97
|
+
factory.assert_not_called()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize("endpoint", ["", "http://collector.invalid", "https://u:p@collector.invalid", "https://collector.invalid?q=secret"])
|
|
101
|
+
def test_invalid_or_unapproved_endpoint_is_dormant(setup, monkeypatch, endpoint):
|
|
102
|
+
marker, factory, _ = setup
|
|
103
|
+
monkeypatch.setattr(telemetry, "ENDPOINT", endpoint)
|
|
104
|
+
telemetry.record_usage("draft")
|
|
105
|
+
assert not marker.exists()
|
|
106
|
+
factory.assert_not_called()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_changed_recipient_requires_new_notice(setup, monkeypatch):
|
|
110
|
+
_, factory, _ = setup
|
|
111
|
+
telemetry.record_usage("draft")
|
|
112
|
+
monkeypatch.setattr(telemetry, "ENDPOINT", "https://other.invalid/usage")
|
|
113
|
+
telemetry.record_usage("draft")
|
|
114
|
+
factory.assert_not_called()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_quiet_no_notice_no_send(setup):
|
|
118
|
+
marker, factory, _ = setup
|
|
119
|
+
telemetry.record_usage("draft", quiet=True)
|
|
120
|
+
assert not marker.exists()
|
|
121
|
+
factory.assert_not_called()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_failures_do_not_escape(setup):
|
|
125
|
+
_, _, client = setup
|
|
126
|
+
telemetry.record_usage("draft")
|
|
127
|
+
client.stream.side_effect = RuntimeError("secret")
|
|
128
|
+
telemetry.record_usage("draft")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_malformed_config_fails_closed(setup):
|
|
132
|
+
marker, factory, _ = setup
|
|
133
|
+
marker.parent.mkdir(parents=True)
|
|
134
|
+
(marker.parent / "config.toml").write_text('[telemetry]\nenabled = "false"\n')
|
|
135
|
+
telemetry.record_usage("draft")
|
|
136
|
+
assert not marker.exists()
|
|
137
|
+
factory.assert_not_called()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_config_set_boolean_validation(tmp_path):
|
|
141
|
+
cfg = Config(_path=tmp_path / "config.toml")
|
|
142
|
+
config_set(cfg, "telemetry_enabled", "false")
|
|
143
|
+
assert Config.load(cfg._path).to_dict()["telemetry"] == {"enabled": False}
|
|
144
|
+
with pytest.raises(ValueError):
|
|
145
|
+
config_set(cfg, "telemetry_enabled", "yes")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_cli_disabling_config_never_calls_telemetry(monkeypatch, tmp_path):
|
|
149
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
150
|
+
track = MagicMock()
|
|
151
|
+
monkeypatch.setattr("typefully_cli.cli.record_usage", track)
|
|
152
|
+
result = CliRunner().invoke(cli, ["config", "set", "telemetry_enabled", "false"])
|
|
153
|
+
assert result.exit_code == 0
|
|
154
|
+
track.assert_not_called()
|
|
155
|
+
assert Config.load().telemetry_enabled is False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_cli_only_passes_category_source_and_quiet_flag(monkeypatch):
|
|
159
|
+
track = MagicMock()
|
|
160
|
+
monkeypatch.setattr("typefully_cli.cli.record_usage", track)
|
|
161
|
+
monkeypatch.setattr("typefully_cli.cli._get_client_and_console", MagicMock(side_effect=RuntimeError))
|
|
162
|
+
CliRunner().invoke(cli, ["draft", "SECRET POST", "--api-key", "SECRET KEY", "--json"])
|
|
163
|
+
track.assert_called_once_with("draft", quiet=True)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_configured_json_mode_excluded(setup):
|
|
167
|
+
marker, factory, _ = setup
|
|
168
|
+
cfg = Config.load()
|
|
169
|
+
config_set(cfg, "output_format", "json")
|
|
170
|
+
telemetry.record_usage("draft")
|
|
171
|
+
assert not marker.exists()
|
|
172
|
+
factory.assert_not_called()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_notice_marker_write_failure_never_sends(setup):
|
|
176
|
+
marker, factory, _ = setup
|
|
177
|
+
marker.mkdir(parents=True)
|
|
178
|
+
telemetry.record_usage("draft")
|
|
179
|
+
telemetry.record_usage("draft")
|
|
180
|
+
factory.assert_not_called()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_actual_transport_has_only_minimal_request_and_no_redirect(setup, monkeypatch):
|
|
184
|
+
requests = []
|
|
185
|
+
|
|
186
|
+
def handler(request):
|
|
187
|
+
requests.append(request)
|
|
188
|
+
return httpx.Response(302, headers={"Location": "https://elsewhere.invalid/steal"})
|
|
189
|
+
|
|
190
|
+
transport = httpx.MockTransport(handler)
|
|
191
|
+
monkeypatch.setattr(
|
|
192
|
+
telemetry.httpx, "Client",
|
|
193
|
+
lambda **kwargs: REAL_HTTPX_CLIENT(transport=transport, **kwargs),
|
|
194
|
+
)
|
|
195
|
+
monkeypatch.setenv("HTTP_PROXY", "http://secret:credential@proxy.invalid")
|
|
196
|
+
monkeypatch.setenv("TYPEFULLY_API_KEY", "SUPER_SECRET")
|
|
197
|
+
telemetry.record_usage("draft")
|
|
198
|
+
telemetry.record_usage("draft")
|
|
199
|
+
assert len(requests) == 1
|
|
200
|
+
request = requests[0]
|
|
201
|
+
assert str(request.url) == "https://collector.invalid/usage"
|
|
202
|
+
assert request.method == "POST"
|
|
203
|
+
assert request.content == b'{"version":"0.4.0","category":"drafts"}'
|
|
204
|
+
assert dict(request.headers) == {
|
|
205
|
+
"host": "collector.invalid",
|
|
206
|
+
"content-type": "application/json",
|
|
207
|
+
"user-agent": "typefully-cli",
|
|
208
|
+
"content-length": str(len(request.content)),
|
|
209
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|