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.
Files changed (56) hide show
  1. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/.github/workflows/ci.yml +7 -0
  2. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/CHANGELOG.md +7 -0
  3. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/PKG-INFO +9 -1
  4. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/README.md +8 -0
  5. typefully_cli-0.4.0/collector/DEPLOYMENT.md +54 -0
  6. typefully_cli-0.4.0/collector/README.md +70 -0
  7. typefully_cli-0.4.0/collector/schema.sql +7 -0
  8. typefully_cli-0.4.0/collector/worker.mjs +72 -0
  9. typefully_cli-0.4.0/collector/worker.test.mjs +123 -0
  10. typefully_cli-0.4.0/collector/wrangler.example.jsonc +15 -0
  11. typefully_cli-0.4.0/docs/release-0.4.0.md +37 -0
  12. typefully_cli-0.4.0/docs/telemetry.md +65 -0
  13. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/pyproject.toml +1 -1
  14. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/skills/typefully-cli/SKILL.md +12 -2
  15. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/__init__.py +1 -1
  16. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/cli.py +7 -0
  17. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/config.py +11 -2
  18. typefully_cli-0.4.0/src/typefully_cli/telemetry.py +85 -0
  19. typefully_cli-0.4.0/tests/test_telemetry.py +209 -0
  20. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/.github/dependabot.yml +0 -0
  21. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/.github/workflows/monitor.yml +0 -0
  22. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/.github/workflows/publish.yml +0 -0
  23. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/.gitignore +0 -0
  24. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/CLAUDE.md +0 -0
  25. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/CONTRIBUTING.md +0 -0
  26. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/Formula/README.md +0 -0
  27. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/Formula/typefully-cli.rb +0 -0
  28. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/LICENSE +0 -0
  29. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/TODOS.md +0 -0
  30. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/docs/batch-format.md +0 -0
  31. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/docs/quote-parity.md +0 -0
  32. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/docs/upstream-parity.md +0 -0
  33. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/requirements.lock +0 -0
  34. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/scripts/monitor.py +0 -0
  35. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/__main__.py +0 -0
  36. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/auth.py +0 -0
  37. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/batch.py +0 -0
  38. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/client.py +0 -0
  39. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/completions.py +0 -0
  40. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/console.py +0 -0
  41. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/exceptions.py +0 -0
  42. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/media.py +0 -0
  43. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/output.py +0 -0
  44. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/payloads.py +0 -0
  45. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/src/typefully_cli/request-schemas.json +0 -0
  46. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/__init__.py +0 -0
  47. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/conftest.py +0 -0
  48. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_auth.py +0 -0
  49. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_batch.py +0 -0
  50. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_cli.py +0 -0
  51. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_client.py +0 -0
  52. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_completions.py +0 -0
  53. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_media.py +0 -0
  54. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_monitor.py +0 -0
  55. {typefully_cli-0.3.0 → typefully_cli-0.4.0}/tests/test_output.py +0 -0
  56. {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.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,7 @@
1
+ CREATE TABLE IF NOT EXISTS daily_counts (
2
+ day TEXT NOT NULL,
3
+ version TEXT NOT NULL,
4
+ category TEXT NOT NULL,
5
+ count INTEGER NOT NULL CHECK (count > 0),
6
+ PRIMARY KEY (day, version, category)
7
+ ) WITHOUT ROWID;
@@ -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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "typefully-cli"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "CLI for the Typefully API -- manage drafts, threads, and publishing from the terminal"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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 maintenance branch's unreleased behavior; older installed
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.
@@ -1,3 +1,3 @@
1
1
  """Typefully CLI -- manage drafts, threads, and publishing from the terminal."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -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 == "api_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