typefully-cli 0.2.1__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 (57) hide show
  1. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/.github/workflows/ci.yml +10 -3
  2. typefully_cli-0.4.0/.github/workflows/monitor.yml +72 -0
  3. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/.github/workflows/publish.yml +1 -1
  4. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/CHANGELOG.md +18 -0
  5. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/CLAUDE.md +10 -12
  6. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/PKG-INFO +49 -3
  7. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/README.md +48 -2
  8. typefully_cli-0.4.0/collector/DEPLOYMENT.md +54 -0
  9. typefully_cli-0.4.0/collector/README.md +70 -0
  10. typefully_cli-0.4.0/collector/schema.sql +7 -0
  11. typefully_cli-0.4.0/collector/worker.mjs +72 -0
  12. typefully_cli-0.4.0/collector/worker.test.mjs +123 -0
  13. typefully_cli-0.4.0/collector/wrangler.example.jsonc +15 -0
  14. typefully_cli-0.4.0/docs/quote-parity.md +79 -0
  15. typefully_cli-0.4.0/docs/release-0.4.0.md +37 -0
  16. typefully_cli-0.4.0/docs/telemetry.md +65 -0
  17. typefully_cli-0.4.0/docs/upstream-parity.md +133 -0
  18. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/pyproject.toml +1 -1
  19. typefully_cli-0.4.0/scripts/monitor.py +147 -0
  20. typefully_cli-0.4.0/skills/typefully-cli/SKILL.md +86 -0
  21. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/src/typefully_cli/__init__.py +1 -1
  22. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/src/typefully_cli/cli.py +308 -44
  23. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/src/typefully_cli/client.py +25 -4
  24. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/src/typefully_cli/config.py +11 -2
  25. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/src/typefully_cli/media.py +2 -1
  26. typefully_cli-0.4.0/src/typefully_cli/payloads.py +195 -0
  27. typefully_cli-0.4.0/src/typefully_cli/request-schemas.json +1220 -0
  28. typefully_cli-0.4.0/src/typefully_cli/telemetry.py +85 -0
  29. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/tests/test_cli.py +338 -0
  30. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/tests/test_completions.py +2 -1
  31. typefully_cli-0.4.0/tests/test_monitor.py +69 -0
  32. typefully_cli-0.4.0/tests/test_payloads.py +216 -0
  33. typefully_cli-0.4.0/tests/test_telemetry.py +209 -0
  34. typefully_cli-0.2.1/.github/workflows/monitor.yml +0 -141
  35. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/.github/dependabot.yml +0 -0
  36. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/.gitignore +0 -0
  37. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/CONTRIBUTING.md +0 -0
  38. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/Formula/README.md +0 -0
  39. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/Formula/typefully-cli.rb +0 -0
  40. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/LICENSE +0 -0
  41. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/TODOS.md +0 -0
  42. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/docs/batch-format.md +0 -0
  43. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/requirements.lock +0 -0
  44. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/src/typefully_cli/__main__.py +0 -0
  45. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/src/typefully_cli/auth.py +0 -0
  46. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/src/typefully_cli/batch.py +0 -0
  47. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/src/typefully_cli/completions.py +0 -0
  48. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/src/typefully_cli/console.py +0 -0
  49. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/src/typefully_cli/exceptions.py +0 -0
  50. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/src/typefully_cli/output.py +0 -0
  51. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/tests/__init__.py +0 -0
  52. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/tests/conftest.py +0 -0
  53. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/tests/test_auth.py +0 -0
  54. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/tests/test_batch.py +0 -0
  55. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/tests/test_client.py +0 -0
  56. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/tests/test_media.py +0 -0
  57. {typefully_cli-0.2.1 → typefully_cli-0.4.0}/tests/test_output.py +0 -0
@@ -8,13 +8,13 @@ on:
8
8
 
9
9
  jobs:
10
10
  test:
11
- runs-on: ubuntu-latest
11
+ runs-on: ubuntu-22.04 # Python 3.9 remains supported; avoid ubuntu-latest image drift.
12
12
  strategy:
13
13
  matrix:
14
14
  python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
15
15
 
16
16
  steps:
17
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
17
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
18
18
 
19
19
  - name: Set up Python ${{ matrix.python-version }}
20
20
  uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -25,7 +25,14 @@ jobs:
25
25
  run: pip install -e ".[dev]"
26
26
 
27
27
  - name: Lint
28
- run: ruff check src/ tests/
28
+ run: ruff check --isolated --select E4,E7,E9,F --target-version py39 --line-length 100 src/ tests/ scripts/
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
@@ -0,0 +1,72 @@
1
+ name: Monitor Typefully API & Competitors
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "0 8 * * 1,4" # Mon & Thu 8am UTC (1:30pm IST)
6
+ workflow_dispatch: {}
7
+
8
+ concurrency:
9
+ group: typefully-upstream-monitor
10
+ cancel-in-progress: false
11
+
12
+ permissions:
13
+ issues: write
14
+ contents: read
15
+
16
+ jobs:
17
+ monitor:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
21
+
22
+ - name: Restore cache
23
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
24
+ with:
25
+ path: .monitor-cache
26
+ key: monitor-cache-${{ github.run_id }}
27
+ restore-keys: monitor-cache-
28
+
29
+ - name: Fetch and validate public upstream sources
30
+ run: python3 scripts/monitor.py
31
+
32
+ - name: Report changes or degraded monitoring
33
+ env:
34
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35
+ run: |
36
+ python3 - <<'PYTHON'
37
+ import json
38
+ import subprocess
39
+ from pathlib import Path
40
+
41
+ cache = Path(".monitor-cache")
42
+ result = json.loads((cache / "result.json").read_text())
43
+ if result["report_needed"]:
44
+ # A stable title deduplicates the same unresolved change/failure.
45
+ issues = json.loads(subprocess.check_output([
46
+ "gh", "issue", "list", "--state", "open", "--search",
47
+ result["fingerprint"] + " in:title",
48
+ "--json", "title", "--limit", "100",
49
+ ], text=True))
50
+ if not any(issue["title"] == result["title"] for issue in issues):
51
+ subprocess.run([
52
+ "gh", "issue", "create", "--title", result["title"],
53
+ "--body-file", str(cache / "report.md"),
54
+ ], check=True)
55
+ subprocess.run(["python3", "scripts/monitor.py", "--commit"], check=True)
56
+ PYTHON
57
+
58
+ - name: Save cache
59
+ uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
60
+ with:
61
+ path: .monitor-cache
62
+ key: monitor-cache-${{ github.run_id }}
63
+
64
+ - name: Fail visibly on degraded monitoring
65
+ run: |
66
+ python3 - <<'PYTHON'
67
+ import json
68
+ from pathlib import Path
69
+ result = json.loads(Path(".monitor-cache/result.json").read_text())
70
+ if result["failed"]:
71
+ raise SystemExit("Upstream fetch failed; see monitoring issue and result.json")
72
+ PYTHON
@@ -11,7 +11,7 @@ jobs:
11
11
  id-token: write # trusted publishing
12
12
 
13
13
  steps:
14
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
14
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
15
15
 
16
16
  - name: Set up Python
17
17
  uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -1,5 +1,23 @@
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
+
10
+ ## 0.3.0 (2026-10-29)
11
+
12
+ - Add schema-backed `draft-payload` request-file input with offline validation, per-post fields and null/false clearing; keep `--json` as output-only.
13
+ - Add X reply/subscriber/community flags, LinkedIn first-comment/reshare fields, upload alt text and media metadata updates.
14
+ - Follow social-set pagination, accept repeated tag filters and reject negative offsets; document every public API operation and unverified MCP/product-only boundaries.
15
+
16
+
17
+ - Repair upstream monitoring: validate official public sources, expose fetch failures, deduplicate reports, and advance baselines only after successful reporting.
18
+ - Pin CI to Ubuntu 22.04 to retain Python 3.9 compatibility; isolate Fish completion tests from the real home directory.
19
+ - Extend `--quote-post-url` / `--qrt` to the documented Threads, Bluesky, Mastodon and Substack quote targets.
20
+
3
21
  ## 0.2.1 (2026-08-21)
4
22
 
5
23
  - Fix trusted PyPI publishing workflow action pin.
@@ -2,13 +2,15 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- Python CLI wrapping the Typefully API v2. Agent-first design: JSON output by default, structured errors on stderr, deterministic behavior. 24 Click commands for drafts, threads, batch, media, analytics, queue, LinkedIn, tags, accounts, config, completions.
5
+ Python CLI wrapping the Typefully API v2. Agent-friendly structured JSON output with `--json`, structured errors on stderr, and configurable text output. See `skills/typefully-cli/SKILL.md` for current agent usage and `docs/upstream-parity.md` for evidence and limitations.
6
6
 
7
7
  ## Architecture
8
8
 
9
9
  ```
10
10
  src/typefully_cli/
11
- cli.py -- 24 Click commands, shared options, error handler, payload builder
11
+ cli.py -- Click commands, shared options, error handler, payload builder
12
+ payloads.py -- Explicit JSON request input, local schema validation, media metadata
13
+ request-schemas.json -- Reviewed public create/update schema snapshot
12
14
  client.py -- httpx wrapper over Typefully API v2 (rate-limited, account resolution)
13
15
  completions.py -- Shell completions install/show (bash/zsh/fish)
14
16
  auth.py -- API key resolution chain: flag > env > config > 1Password
@@ -29,7 +31,7 @@ src/typefully_cli/
29
31
  - **Timezone conversion:** Config `timezone` (IANA name) converts naive schedule datetimes to UTC before API call. Already-tz-aware values pass through unchanged.
30
32
  - **Partial failure:** Delete and batch loops catch per-item errors. `_handle_multi_result()` handles the 3-branch contract (all succeed / partial / all fail).
31
33
  - **Error sanitization:** `APIError.user_message` property strips raw upstream bodies from stdout payloads. Full details stay on stderr.
32
- - **API parity:** Tracks all Typefully API v2 endpoints including analytics, queue management, and LinkedIn org resolution.
34
+ - **API parity:** Map each public operation in docs/upstream-parity.md. Do not claim exhaustive MCP parity; analytics and legacy convenience fields are not verified by the current public schema.
33
35
 
34
36
  ## Dev setup
35
37
 
@@ -41,19 +43,15 @@ pip install -e ".[dev]"
41
43
 
42
44
  ```bash
43
45
  python3 -m pytest tests/ -v
44
- ruff check src/ tests/
46
+ ruff check --isolated --select E4,E7,E9,F --target-version py39 --line-length 100 src/ tests/ scripts/
45
47
  ```
46
48
 
47
49
  ## Auth for live testing
48
50
 
49
- Config at `~/.config/typefully/config.toml`. Test account: BungeeCEO.
50
-
51
- ```bash
52
- typefully config set api_key tf_...
53
- typefully config set default_account BungeeCEO
54
- typefully config set default_platforms x,linkedin
55
- typefully config set timezone Asia/Kolkata
56
- ```
51
+ Use existing authorized access from environment/config; never assume an account
52
+ named in documentation is a sandbox. No cloud credential is bundled with this repo.
53
+ Use local `--validate-only` and mocked tests first. Live test writes need explicit
54
+ scope and a verified social set; never publish as part of maintenance tests.
57
55
 
58
56
  ## Conventions
59
57
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: typefully-cli
3
- Version: 0.2.1
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
@@ -37,8 +37,8 @@ Description-Content-Type: text/markdown
37
37
 
38
38
  Publish to X, LinkedIn, Bluesky, Threads, Mastodon, Substack Notes, and X Articles from your terminal. Goes from idea to post without opening a browser.
39
39
 
40
- [![PyPI](https://img.shields.io/pypi/v/typefully-cli?v=1)](https://pypi.org/project/typefully-cli/)
41
- [![Python 3.9+](https://img.shields.io/pypi/pyversions/typefully-cli?v=1)](https://pypi.org/project/typefully-cli/)
40
+ [![PyPI](https://img.shields.io/pypi/v/typefully-cli.svg)](https://pypi.org/project/typefully-cli/)
41
+ [![Python 3.9+](https://img.shields.io/pypi/pyversions/typefully-cli.svg)](https://pypi.org/project/typefully-cli/)
42
42
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
43
43
 
44
44
  ## Why
@@ -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).
@@ -338,3 +346,41 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
338
346
  ## License
339
347
 
340
348
  MIT
349
+
350
+ ## Upstream compatibility maintenance
351
+
352
+ The public-source monitor opens issues for upstream changes or failed checks; it does not
353
+ implement or merge updates. See the [source and capability baseline](docs/upstream-parity.md)
354
+ for monitored sources, known limits, and how maintenance coordinates with Dependabot.
355
+
356
+ Native quotes route to the matching selected platform (the existing `--qrt` alias still works):
357
+
358
+ ```sh
359
+ typefully draft "My take" --platform bluesky --quote-post-url https://bsky.app/profile/example.com/post/abc
360
+ ```
361
+
362
+ Threads, Bluesky, Mastodon and Substack quotes apply to the first new post. Existing X
363
+ thread behavior is retained. See [quote compatibility notes](docs/quote-parity.md).
364
+
365
+ ### Complete request input and media alt text
366
+
367
+ `draft-payload create --file draft.json --validate-only` validates the reviewed public
368
+ API schema offline. Remove `--validate-only` and select `--account` to submit.
369
+ Use `draft-payload update --draft-id ID --file update.json` for per-post settings,
370
+ explicit null/false clearing, or advanced fields. This is **request input**;
371
+ `--json` on existing commands only controls output. The new advanced commands
372
+ always emit JSON. Platform arrays replace content: retain posts/comment markers
373
+ and settings you want to keep. `publish_at: "now"` really requests publication.
374
+
375
+ `upload image.png --alt-text "Description"` sets alt text before attachment.
376
+ `media-update MEDIA_ID --alt-text "New description"` or `--clear-alt-text` changes
377
+ stored media; previously attached drafts keep their existing metadata snapshot.
378
+ See [the full public capability matrix and remaining MCP boundaries](docs/upstream-parity.md).
379
+
380
+ ### Agent skill
381
+
382
+ The repository's [Typefully CLI skill](skills/typefully-cli/SKILL.md) describes these
383
+ Python CLI commands, local validation and authorization boundaries. It is distinct
384
+ from Typefully's official JavaScript agent skill. Check the installed CLI's help:
385
+ maintenance-branch features are not available in an older published package until
386
+ an approved release is made.
@@ -2,8 +2,8 @@
2
2
 
3
3
  Publish to X, LinkedIn, Bluesky, Threads, Mastodon, Substack Notes, and X Articles from your terminal. Goes from idea to post without opening a browser.
4
4
 
5
- [![PyPI](https://img.shields.io/pypi/v/typefully-cli?v=1)](https://pypi.org/project/typefully-cli/)
6
- [![Python 3.9+](https://img.shields.io/pypi/pyversions/typefully-cli?v=1)](https://pypi.org/project/typefully-cli/)
5
+ [![PyPI](https://img.shields.io/pypi/v/typefully-cli.svg)](https://pypi.org/project/typefully-cli/)
6
+ [![Python 3.9+](https://img.shields.io/pypi/pyversions/typefully-cli.svg)](https://pypi.org/project/typefully-cli/)
7
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
8
 
9
9
  ## Why
@@ -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).
@@ -303,3 +311,41 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
303
311
  ## License
304
312
 
305
313
  MIT
314
+
315
+ ## Upstream compatibility maintenance
316
+
317
+ The public-source monitor opens issues for upstream changes or failed checks; it does not
318
+ implement or merge updates. See the [source and capability baseline](docs/upstream-parity.md)
319
+ for monitored sources, known limits, and how maintenance coordinates with Dependabot.
320
+
321
+ Native quotes route to the matching selected platform (the existing `--qrt` alias still works):
322
+
323
+ ```sh
324
+ typefully draft "My take" --platform bluesky --quote-post-url https://bsky.app/profile/example.com/post/abc
325
+ ```
326
+
327
+ Threads, Bluesky, Mastodon and Substack quotes apply to the first new post. Existing X
328
+ thread behavior is retained. See [quote compatibility notes](docs/quote-parity.md).
329
+
330
+ ### Complete request input and media alt text
331
+
332
+ `draft-payload create --file draft.json --validate-only` validates the reviewed public
333
+ API schema offline. Remove `--validate-only` and select `--account` to submit.
334
+ Use `draft-payload update --draft-id ID --file update.json` for per-post settings,
335
+ explicit null/false clearing, or advanced fields. This is **request input**;
336
+ `--json` on existing commands only controls output. The new advanced commands
337
+ always emit JSON. Platform arrays replace content: retain posts/comment markers
338
+ and settings you want to keep. `publish_at: "now"` really requests publication.
339
+
340
+ `upload image.png --alt-text "Description"` sets alt text before attachment.
341
+ `media-update MEDIA_ID --alt-text "New description"` or `--clear-alt-text` changes
342
+ stored media; previously attached drafts keep their existing metadata snapshot.
343
+ See [the full public capability matrix and remaining MCP boundaries](docs/upstream-parity.md).
344
+
345
+ ### Agent skill
346
+
347
+ The repository's [Typefully CLI skill](skills/typefully-cli/SKILL.md) describes these
348
+ Python CLI commands, local validation and authorization boundaries. It is distinct
349
+ from Typefully's official JavaScript agent skill. Check the installed CLI's help:
350
+ maintenance-branch features are not available in an older published package until
351
+ an approved release is made.
@@ -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
+ });