insightfactory-cli 1.2.0.dev34__tar.gz → 1.3.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 (81) hide show
  1. insightfactory_cli-1.3.0/.github/workflows/claude.yml +144 -0
  2. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/CHANGELOG.md +34 -0
  3. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/CLAUDE.md +3 -2
  4. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/PKG-INFO +24 -9
  5. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/README.md +23 -8
  6. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/pyproject.toml +1 -1
  7. insightfactory_cli-1.3.0/src/if_cli/cache.py +318 -0
  8. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/commands/logout.py +3 -4
  9. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/commands/profiles.py +3 -3
  10. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/commands/set_token.py +1 -1
  11. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/oauth.py +13 -14
  12. insightfactory_cli-1.3.0/tests/cache_writer.py +16 -0
  13. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_attribution.py +1 -1
  14. insightfactory_cli-1.3.0/tests/test_cache.py +290 -0
  15. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_cli.py +2 -2
  16. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_mcp_command.py +6 -2
  17. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_oauth_flow.py +113 -21
  18. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_oauth_force_refresh.py +3 -3
  19. insightfactory_cli-1.3.0/tests/test_profile_tokens.py +170 -0
  20. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_router_upstream.py +26 -2
  21. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_set_token.py +1 -3
  22. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/uv.lock +1 -1
  23. insightfactory_cli-1.2.0.dev34/.github/workflows/claude.yml +0 -78
  24. insightfactory_cli-1.2.0.dev34/src/if_cli/cache.py +0 -206
  25. insightfactory_cli-1.2.0.dev34/tests/cache_writer.py +0 -14
  26. insightfactory_cli-1.2.0.dev34/tests/test_cache.py +0 -146
  27. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/.github/workflows/ci.yml +0 -0
  28. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/.github/workflows/release.yml +0 -0
  29. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/.gitignore +0 -0
  30. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/.python-version +0 -0
  31. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/AGENTS.md +0 -0
  32. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/LICENSE +0 -0
  33. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/__init__.py +0 -0
  34. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/__main__.py +0 -0
  35. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/assets/__init__.py +0 -0
  36. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/assets/insightfactoryai-logo.svg +0 -0
  37. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/attribution.py +0 -0
  38. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/callback_page.py +0 -0
  39. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/cli.py +0 -0
  40. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/colour.py +0 -0
  41. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/commands/__init__.py +0 -0
  42. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/commands/api.py +0 -0
  43. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/commands/config.py +0 -0
  44. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/commands/login.py +0 -0
  45. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/commands/mcp.py +0 -0
  46. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/commands/token.py +0 -0
  47. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/config.py +0 -0
  48. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/constants.py +0 -0
  49. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/http.py +0 -0
  50. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/main.py +0 -0
  51. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/router/__init__.py +0 -0
  52. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/router/catalog.py +0 -0
  53. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/router/content.py +0 -0
  54. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/router/log.py +0 -0
  55. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/router/policy.py +0 -0
  56. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/router/server.py +0 -0
  57. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/router/upstream.py +0 -0
  58. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/src/if_cli/runtime.py +0 -0
  59. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/__init__.py +0 -0
  60. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/conftest.py +0 -0
  61. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/helpers.py +0 -0
  62. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/servers.py +0 -0
  63. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_api.py +0 -0
  64. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_api_command.py +0 -0
  65. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_config.py +0 -0
  66. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_config_command.py +0 -0
  67. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_login.py +0 -0
  68. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_oauth.py +0 -0
  69. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_profiles.py +0 -0
  70. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_programmatic_api.py +0 -0
  71. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_release_tools.py +0 -0
  72. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_router_catalog.py +0 -0
  73. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_router_content.py +0 -0
  74. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_router_log.py +0 -0
  75. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_router_policy.py +0 -0
  76. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_router_resources.py +0 -0
  77. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_router_server.py +0 -0
  78. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_runtime.py +0 -0
  79. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tests/test_token.py +0 -0
  80. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tools/check_release.py +0 -0
  81. {insightfactory_cli-1.2.0.dev34 → insightfactory_cli-1.3.0}/tools/release_notes.py +0 -0
@@ -0,0 +1,144 @@
1
+ # =======================================================
2
+ # GitHub Actions Workflow: Claude Code Assistant
3
+ #
4
+ # Purpose: AI-powered code review and assistance, on demand only.
5
+ # Triggered by mentioning @claude in a PR comment, an issue
6
+ # comment, or an inline PR review comment (e.g. `@claude review`).
7
+ # @agent-review on a PR runs Claude and Codex together through the
8
+ # shared agent review instead. There is no automatic review on PR open.
9
+ #
10
+ # =======================================================
11
+
12
+ name: Claude Workflow
13
+
14
+ on:
15
+ issue_comment:
16
+ types: [created]
17
+ pull_request_review_comment:
18
+ types: [created]
19
+
20
+ # No `permissions:` block here on purpose. A called workflow may only reduce the
21
+ # caller's grant, never raise it, and GitHub validates that at startup -- so
22
+ # capping this to `contents: read` made every run fail with `startup_failure`
23
+ # before any job ran. if_s_claude_review.yml declares the scopes it needs
24
+ # (id-token/pull-requests/issues write, actions read); leaving the caller
25
+ # unrestricted is what every other repo in the org that calls it does.
26
+
27
+ jobs:
28
+ claude:
29
+ # Author gating. The comment body is attacker-controlled and the call below
30
+ # inherits every secret, so restrict the trigger to people with write-ish
31
+ # access rather than anyone who can leave a comment.
32
+ if: |
33
+ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) &&
34
+ contains(github.event.comment.body, '@claude')
35
+ uses: insightfactory-ai/if_sre_github_actions/.github/workflows/if_s_claude_review.yml@main
36
+ secrets: inherit
37
+ with:
38
+ # CONSTRAINT: the value below is interpolated inside a double-quoted CLI argument with no
39
+ # escaping. Do NOT include ASCII double-quote characters. Use single quotes or backticks
40
+ # if you need to quote something. Newlines are fine.
41
+ append_system_prompt: |
42
+ This repository (insightfactory-cli) is the production Python `if-cli`, published to public
43
+ PyPI. Read CLAUDE.md and README.md before reviewing; the rules below are the ones that get
44
+ violated most often.
45
+
46
+ Runtime and layout:
47
+ - Runtime code is stdlib-only. Flag any new runtime dependency in pyproject.toml or any
48
+ import outside the standard library.
49
+ - Keep one module per CLI command under `src/if_cli/commands/`.
50
+ - Do not suggest third-party HTTP, OAuth, or CLI frameworks; the codebase uses urllib and
51
+ argparse deliberately.
52
+
53
+ Security and auth:
54
+ - A profile's bearer token must never be sent outside that profile's factory origin. Flag
55
+ any code path that could forward a token to another host.
56
+ - OAuth uses authorization-code flow with PKCE and a loopback-only (`127.0.0.1`) callback.
57
+ The CLI is a public client with no client secret.
58
+ - Do not commit credentials, access tokens, refresh tokens, or client secrets in code,
59
+ tests, or fixtures. Fixtures must use neutral hostnames such as `factory.example`.
60
+
61
+ On-disk compatibility:
62
+ - Profiles and tokens must be written only through the atomic private-file helpers
63
+ (`write_private_file` in config.py): `0o600` files, `0o700` directories, atomic replace
64
+ via temp file.
65
+ - The Node CLI is retired. Profile sections keep the Node layout (`~/.insightfactory`,
66
+ `INSIGHTFACTORY_CONFIG_DIR` override). `token-cache.json` is version 2: keyed by
67
+ profile name, with the host in each entry. Older `if-cli` releases and the Node CLI
68
+ cannot read it, and that is accepted. Reading a version 1 cache must keep working.
69
+
70
+ Programmatic API (breaking-change surface):
71
+ - These four symbols are a supported in-process surface documented in README.md:
72
+ `if_cli.config.load_config`, `if_cli.config.get_profile`, `if_cli.oauth.get_valid_token`,
73
+ `if_cli.runtime.CliError`.
74
+ - They must raise `CliError` on failure (never `SystemExit`), never write to stdout, never
75
+ launch a browser or block on interactive input, and use only bounded waits.
76
+ - Changing signatures, behaviour, or removing any of them requires a major version bump.
77
+
78
+ Tests:
79
+ - `tests/` uses isolated config dirs via `INSIGHTFACTORY_CONFIG_DIR`; never smoke-test
80
+ against `~/.insightfactory`.
81
+
82
+ agent-review:
83
+ # Claude and Codex review the PR together, one matrix job each. The shared
84
+ # workflow's request check refuses anything but a PR comment from someone with
85
+ # write access that names @agent-review as a whole word.
86
+ if: >-
87
+ contains(github.event.comment.body, '@agent-review')
88
+ && (github.event_name == 'pull_request_review_comment' || github.event.issue.pull_request)
89
+ permissions:
90
+ id-token: write
91
+ contents: read
92
+ pull-requests: write
93
+ issues: write
94
+ actions: read
95
+ checks: read
96
+ statuses: read
97
+ uses: insightfactory-ai/if_sre_github_actions/.github/workflows/if_s_agent_review.yml@main
98
+ secrets: inherit
99
+ with:
100
+ # A reviewer approves only an approved verdict with no P0, P1 or P2, and a
101
+ # person from the required team still approves every PR (the Approvers
102
+ # ruleset on develop, test and main).
103
+ allow_agent_approval: true
104
+ # The same instructions as the @claude job above; keep the two in step.
105
+ append_system_prompt: |
106
+ This repository (insightfactory-cli) is the production Python `if-cli`, published to public
107
+ PyPI. Read CLAUDE.md and README.md before reviewing; the rules below are the ones that get
108
+ violated most often.
109
+
110
+ Runtime and layout:
111
+ - Runtime code is stdlib-only. Flag any new runtime dependency in pyproject.toml or any
112
+ import outside the standard library.
113
+ - Keep one module per CLI command under `src/if_cli/commands/`.
114
+ - Do not suggest third-party HTTP, OAuth, or CLI frameworks; the codebase uses urllib and
115
+ argparse deliberately.
116
+
117
+ Security and auth:
118
+ - A profile's bearer token must never be sent outside that profile's factory origin. Flag
119
+ any code path that could forward a token to another host.
120
+ - OAuth uses authorization-code flow with PKCE and a loopback-only (`127.0.0.1`) callback.
121
+ The CLI is a public client with no client secret.
122
+ - Do not commit credentials, access tokens, refresh tokens, or client secrets in code,
123
+ tests, or fixtures. Fixtures must use neutral hostnames such as `factory.example`.
124
+
125
+ On-disk compatibility:
126
+ - Profiles and tokens must be written only through the atomic private-file helpers
127
+ (`write_private_file` in config.py): `0o600` files, `0o700` directories, atomic replace
128
+ via temp file.
129
+ - The Node CLI is retired. Profile sections keep the Node layout (`~/.insightfactory`,
130
+ `INSIGHTFACTORY_CONFIG_DIR` override). `token-cache.json` is version 2: keyed by
131
+ profile name, with the host in each entry. Older `if-cli` releases and the Node CLI
132
+ cannot read it, and that is accepted. Reading a version 1 cache must keep working.
133
+
134
+ Programmatic API (breaking-change surface):
135
+ - These four symbols are a supported in-process surface documented in README.md:
136
+ `if_cli.config.load_config`, `if_cli.config.get_profile`, `if_cli.oauth.get_valid_token`,
137
+ `if_cli.runtime.CliError`.
138
+ - They must raise `CliError` on failure (never `SystemExit`), never write to stdout, never
139
+ launch a browser or block on interactive input, and use only bounded waits.
140
+ - Changing signatures, behaviour, or removing any of them requires a major version bump.
141
+
142
+ Tests:
143
+ - `tests/` uses isolated config dirs via `INSIGHTFACTORY_CONFIG_DIR`; never smoke-test
144
+ against `~/.insightfactory`.
@@ -11,6 +11,40 @@ that: every PR into `develop` must add an entry here, and a tag whose version ha
11
11
  section fails rather than releasing an empty page. The version only moves when the
12
12
  last one has been released, so a cycle's PRs share a section.
13
13
 
14
+ ## 1.3.0
15
+
16
+ ### Changed
17
+
18
+ - Internal only: a PR comment naming `@agent-review` runs the shared Claude and
19
+ Codex review, which can approve a PR that a person from the required team
20
+ still approves. The CLI itself is unchanged.
21
+ - `logout` names the profile as well as the host in its output, for example
22
+ `✓ cleared cached token for profile 'dev' (https://factory.example)`, because
23
+ two profiles can now share a host.
24
+
25
+ ### Fixed
26
+
27
+ - The token cache is keyed by profile, not by host. Before, two profiles on the
28
+ same factory host shared one token, so signing in as a second account on one
29
+ profile silently replaced the other profile's token, and that profile then
30
+ acted as the second account. Each profile now keeps its own token through
31
+ `login`, refresh, `token`, `api`, `mcp`, `profiles`, `set-token` and `logout`.
32
+ Each entry records the host that issued it, and a profile whose `host` changes
33
+ stops using it, so the token is not sent to the new origin.
34
+ - `token-cache.json` moves to version 2. The CLI reads a version 1 cache and
35
+ gives each host's token to every profile on that host, so upgrading needs no
36
+ new login. The first write saves version 2. Older `if-cli` releases cannot
37
+ read version 2 and stop on every command, `login` included. To downgrade,
38
+ move `token-cache.json` aside, then sign in to each profile again.
39
+ - A version 1 cache cannot say which account its host entry holds. If two
40
+ profiles on one host signed in as different accounts before this release, both
41
+ start out holding whichever account signed in last. Run
42
+ `if-cli login -p <profile>` once for each such profile to give it its own
43
+ account.
44
+ - Internal only: the test that `mcp` answers `initialize` before the catalogue
45
+ build finishes allows 3 seconds instead of 1, because starting the subprocess
46
+ on a CI runner took just over a second and failed the test at random.
47
+
14
48
  ## 1.2.0
15
49
 
16
50
  ### Added
@@ -22,8 +22,9 @@ uv run ty check
22
22
  - OAuth uses authorization-code flow with PKCE and a loopback-only callback.
23
23
  - Store profiles and tokens through the atomic private-file helpers.
24
24
  - The Node CLI is retired; `[factory <name>]` sections are Python-only. Profile
25
- sections and the token store keep the Node layout (`~/.insightfactory`,
26
- `INSIGHTFACTORY_CONFIG_DIR` override).
25
+ sections keep the Node layout (`~/.insightfactory`, `INSIGHTFACTORY_CONFIG_DIR`
26
+ override). `token-cache.json` is version 2: keyed by profile name, with the
27
+ host in each entry. An older `if-cli` cannot read it.
27
28
  - Do not commit credentials, access tokens, refresh tokens, or client secrets.
28
29
  - Test fixtures use only neutral hostnames (`factory.example`).
29
30
  - Do not publish to PyPI from a developer machine. Publishing happens in CI: the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: insightfactory-cli
3
- Version: 1.2.0.dev34
3
+ Version: 1.3.0
4
4
  Summary: Profile-based authentication CLI for the InsightFactory Interfaces API
5
5
  Project-URL: Homepage, https://insightfactory.ai
6
6
  Author-email: "insightfactory.ai Support" <support@insightfactory.ai>
@@ -27,9 +27,9 @@ Description-Content-Type: text/markdown
27
27
  Profile-based authentication CLI for the InsightFactory Interfaces API. It
28
28
  installs the `if-cli` command, which logs in through the browser with OAuth
29
29
  authorization code and PKCE, refreshes tokens, and makes authenticated API
30
- calls. Profiles live in `~/.insightfactory` in the same layout the Node
31
- `@insightfactory-ai/if-cli` uses, so both implementations can share one config
32
- directory.
30
+ calls. Profiles live in `~/.insightfactory` in the same layout the retired Node
31
+ `@insightfactory-ai/if-cli` used. The token cache uses its own format, described
32
+ under "How authentication works".
33
33
 
34
34
  This is the production Python CLI, published to public PyPI. It needs Python
35
35
  3.10 or newer.
@@ -229,8 +229,20 @@ rejected.
229
229
  - Profiles live in `~/.insightfactory/config`, one per customer and environment.
230
230
  - The CLI manages the profile file and regenerates it on updates. Comments and
231
231
  empty sections are not preserved.
232
- - Tokens are cached by host in `~/.insightfactory/token-cache.json` with file
233
- mode `0600`.
232
+ - Tokens are cached per profile in `~/.insightfactory/token-cache.json` with
233
+ file mode `0600`. Two profiles on the same host can sign in as different
234
+ accounts, and each keeps its own token. Each entry records the host that
235
+ issued it, and a profile uses it only while its `host` still matches, so a
236
+ token never goes to another origin after a host change.
237
+ - `if-cli` 1.3.0 and later write version 2 of the cache, keyed by profile
238
+ name. They read the older version 1 cache, keyed by host, and give its token
239
+ to every profile on that host, so an upgrade needs no new login. Older
240
+ `if-cli` releases and the Node CLI cannot read version 2 and stop on every
241
+ command, `login` included. To downgrade, move `token-cache.json` aside, then
242
+ sign in to each profile again.
243
+ - A version 1 cache holds one account per host. If two profiles on one host
244
+ signed in as different accounts before 1.3.0, both start out as whichever
245
+ account signed in last. Run `if-cli login -p <profile>` once for each of them.
234
246
  - The configuration directory is `0700`. Configuration and cache updates are
235
247
  atomic.
236
248
  - Remote factory, authorization, and token endpoints must use HTTPS. HTTP is
@@ -512,9 +524,12 @@ differ:
512
524
  - **`--version`** reads the installed package metadata from `1.0.0` onward
513
525
  rather than an npm `package.json`.
514
526
 
515
- On-disk profile and token-cache paths, JSON shapes, and file permissions stay
516
- compatible with the Node CLI, so you can switch implementations without
517
- re-authenticating.
527
+ On-disk profile and token-cache paths and file permissions match the Node CLI.
528
+ The token cache's JSON shape does not: `if-cli` keys it by profile (version 2),
529
+ which the Node CLI and older `if-cli` releases cannot read. The Python CLI
530
+ still reads a Node-written version 1 cache, so moving from Node to Python needs
531
+ no new login. To move back, move `token-cache.json` aside and sign in to each
532
+ profile again.
518
533
 
519
534
  ## Licence
520
535
 
@@ -3,9 +3,9 @@
3
3
  Profile-based authentication CLI for the InsightFactory Interfaces API. It
4
4
  installs the `if-cli` command, which logs in through the browser with OAuth
5
5
  authorization code and PKCE, refreshes tokens, and makes authenticated API
6
- calls. Profiles live in `~/.insightfactory` in the same layout the Node
7
- `@insightfactory-ai/if-cli` uses, so both implementations can share one config
8
- directory.
6
+ calls. Profiles live in `~/.insightfactory` in the same layout the retired Node
7
+ `@insightfactory-ai/if-cli` used. The token cache uses its own format, described
8
+ under "How authentication works".
9
9
 
10
10
  This is the production Python CLI, published to public PyPI. It needs Python
11
11
  3.10 or newer.
@@ -205,8 +205,20 @@ rejected.
205
205
  - Profiles live in `~/.insightfactory/config`, one per customer and environment.
206
206
  - The CLI manages the profile file and regenerates it on updates. Comments and
207
207
  empty sections are not preserved.
208
- - Tokens are cached by host in `~/.insightfactory/token-cache.json` with file
209
- mode `0600`.
208
+ - Tokens are cached per profile in `~/.insightfactory/token-cache.json` with
209
+ file mode `0600`. Two profiles on the same host can sign in as different
210
+ accounts, and each keeps its own token. Each entry records the host that
211
+ issued it, and a profile uses it only while its `host` still matches, so a
212
+ token never goes to another origin after a host change.
213
+ - `if-cli` 1.3.0 and later write version 2 of the cache, keyed by profile
214
+ name. They read the older version 1 cache, keyed by host, and give its token
215
+ to every profile on that host, so an upgrade needs no new login. Older
216
+ `if-cli` releases and the Node CLI cannot read version 2 and stop on every
217
+ command, `login` included. To downgrade, move `token-cache.json` aside, then
218
+ sign in to each profile again.
219
+ - A version 1 cache holds one account per host. If two profiles on one host
220
+ signed in as different accounts before 1.3.0, both start out as whichever
221
+ account signed in last. Run `if-cli login -p <profile>` once for each of them.
210
222
  - The configuration directory is `0700`. Configuration and cache updates are
211
223
  atomic.
212
224
  - Remote factory, authorization, and token endpoints must use HTTPS. HTTP is
@@ -488,9 +500,12 @@ differ:
488
500
  - **`--version`** reads the installed package metadata from `1.0.0` onward
489
501
  rather than an npm `package.json`.
490
502
 
491
- On-disk profile and token-cache paths, JSON shapes, and file permissions stay
492
- compatible with the Node CLI, so you can switch implementations without
493
- re-authenticating.
503
+ On-disk profile and token-cache paths and file permissions match the Node CLI.
504
+ The token cache's JSON shape does not: `if-cli` keys it by profile (version 2),
505
+ which the Node CLI and older `if-cli` releases cannot read. The Python CLI
506
+ still reads a Node-written version 1 cache, so moving from Node to Python needs
507
+ no new login. To move back, move `token-cache.json` aside and sign in to each
508
+ profile again.
494
509
 
495
510
  ## Licence
496
511
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "insightfactory-cli"
3
- version = "1.2.0.dev34"
3
+ version = "1.3.0"
4
4
  description = "Profile-based authentication CLI for the InsightFactory Interfaces API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import os
7
+ import time
8
+ from collections.abc import Callable
9
+ from typing import NoReturn, TypedDict, TypeGuard, TypeVar
10
+
11
+ from if_cli.config import cache_file, get_profile, is_factory_section, load_config, write_private_file
12
+ from if_cli.constants import EXPIRY_SLACK_SECONDS
13
+ from if_cli.runtime import die, is_record
14
+
15
+ CACHE_LOCK_STALE_MS = 30_000
16
+ CACHE_VERSION = 2
17
+ HOST_KEYED_CACHE_VERSION = 1
18
+
19
+
20
+ class CacheEntry(TypedDict, total=False):
21
+ host: str
22
+ grant: str
23
+ access_token: str
24
+ refresh_token: str
25
+ expires_at: float
26
+ token_endpoint: str
27
+ client_id: str
28
+
29
+
30
+ class TokenCache(TypedDict):
31
+ """Version 2 keys `tokens` by profile name. Each entry records the host it was
32
+ issued for, and is used only while the profile still points at that host."""
33
+
34
+ version: int
35
+ tokens: dict[str, CacheEntry]
36
+
37
+
38
+ class TokenOwner(TypedDict):
39
+ """The part of a `Profile` that owns a cache entry."""
40
+
41
+ name: str
42
+ host: str
43
+
44
+
45
+ def empty_cache() -> TokenCache:
46
+ return {"version": CACHE_VERSION, "tokens": {}}
47
+
48
+
49
+ def entry_for(cache: TokenCache, owner: TokenOwner) -> CacheEntry | None:
50
+ """The profile's entry, unless it was issued for a host the profile no longer
51
+ names: a bearer token never goes to an origin other than the one it came from."""
52
+ entry = cache["tokens"].get(owner["name"])
53
+ if entry is None or entry.get("host") != owner["host"]:
54
+ return None
55
+ return entry
56
+
57
+
58
+ def _siblings(cache: TokenCache, owner: TokenOwner, grant: str | None) -> list[str]:
59
+ """Other profiles on the same host that share the owner's `grant`.
60
+
61
+ Only a version 1 cache produces these: it held one entry per host, which the
62
+ upgrade copies to every profile on that host, marking the copies with one
63
+ `grant` id. They are one sign-in, so a rotation or rejection on one applies to
64
+ all of them; leaving a sibling on the rotated-out refresh token would replay it
65
+ on its next refresh. The id outlives a cleared refresh token, so a refresh that
66
+ lands after a peer's invalid_grant still reaches every sibling. A login or a
67
+ pasted token starts a fresh entry without it, which ends the sharing.
68
+ """
69
+ if not grant:
70
+ return []
71
+ return [
72
+ name
73
+ for name, entry in cache["tokens"].items()
74
+ if name != owner["name"] and entry.get("host") == owner["host"] and entry.get("grant") == grant
75
+ ]
76
+
77
+
78
+ def _positive_integer_env(name: str, fallback: int) -> int:
79
+ raw = os.environ.get(name)
80
+ if raw is None:
81
+ return fallback
82
+ try:
83
+ value = float(raw)
84
+ except (TypeError, ValueError):
85
+ return fallback
86
+ if math.isfinite(value) and value == int(value) and value > 0:
87
+ return int(value)
88
+ return fallback
89
+
90
+
91
+ def _cache_lock_file() -> str:
92
+ return f"{cache_file()}.lock"
93
+
94
+
95
+ def _cache_lock_attempts() -> int:
96
+ return _positive_integer_env("INSIGHTFACTORY_CACHE_LOCK_ATTEMPTS", 200)
97
+
98
+
99
+ def _cache_lock_retry_ms() -> int:
100
+ return _positive_integer_env("INSIGHTFACTORY_CACHE_LOCK_RETRY_MS", 25)
101
+
102
+
103
+ def is_fresh(entry: CacheEntry, now_seconds: float | None = None) -> bool:
104
+ if now_seconds is None:
105
+ now_seconds = time.time()
106
+ return entry["expires_at"] - EXPIRY_SLACK_SECONDS > now_seconds
107
+
108
+
109
+ def _is_cache_entry(value: object, *, with_host: bool) -> TypeGuard[CacheEntry]:
110
+ if not is_record(value):
111
+ return False
112
+ if with_host and not isinstance(value.get("host"), str):
113
+ return False
114
+ if "grant" in value and not isinstance(value.get("grant"), str):
115
+ return False
116
+ access_token = value.get("access_token")
117
+ expires_at = value.get("expires_at")
118
+ token_endpoint = value.get("token_endpoint")
119
+ refresh_token = value.get("refresh_token")
120
+ client_id = value.get("client_id")
121
+ return (
122
+ isinstance(access_token, str)
123
+ and isinstance(expires_at, (int, float))
124
+ and not isinstance(expires_at, bool)
125
+ and math.isfinite(float(expires_at))
126
+ and isinstance(token_endpoint, str)
127
+ and ("refresh_token" not in value or isinstance(refresh_token, str))
128
+ and ("client_id" not in value or isinstance(client_id, str))
129
+ )
130
+
131
+
132
+ def _invalid_structure() -> NoReturn:
133
+ die(f"token cache {cache_file()} has an invalid structure; move it aside and log in again")
134
+
135
+
136
+ def _parse_entries(tokens_value: dict[str, object], *, with_host: bool) -> dict[str, CacheEntry]:
137
+ tokens: dict[str, CacheEntry] = {}
138
+ for key, entry in tokens_value.items():
139
+ if not _is_cache_entry(entry, with_host=with_host):
140
+ die(f"token cache entry for {key} is invalid; move {cache_file()} aside and log in again")
141
+ tokens[key] = entry
142
+ return tokens
143
+
144
+
145
+ def _from_host_keyed(tokens_by_host: dict[str, CacheEntry]) -> dict[str, CacheEntry]:
146
+ """Give each configured profile the version 1 entry for its host.
147
+
148
+ A host entry with no profile left on that host is unreachable, so it is
149
+ dropped. A profile that does not resolve gets nothing, as it could not have
150
+ used a token before either.
151
+ """
152
+ config = load_config()
153
+ tokens: dict[str, CacheEntry] = {}
154
+ for section in config:
155
+ if is_factory_section(section):
156
+ continue
157
+ name = "DEFAULT" if section == "" else section
158
+ try:
159
+ host = get_profile(config, name)["host"]
160
+ except Exception:
161
+ # Not only CliError: a bad port raises ValueError or OverflowError. One
162
+ # broken profile must not stop every other profile's cache read.
163
+ continue
164
+ entry = tokens_by_host.get(host)
165
+ if entry is not None:
166
+ migrated = entry.copy()
167
+ migrated["host"] = host
168
+ tokens[name] = migrated
169
+ hosts = [entry["host"] for entry in tokens.values()]
170
+ for entry in tokens.values():
171
+ refresh_token = entry.get("refresh_token")
172
+ if refresh_token and hosts.count(entry["host"]) > 1:
173
+ # Derived, not random: every process reading the same version 1 file
174
+ # agrees on it until the first write saves version 2.
175
+ entry["grant"] = hashlib.sha256(f"{entry['host']}\n{refresh_token}".encode()).hexdigest()[:32]
176
+ return tokens
177
+
178
+
179
+ def _parse_cache(raw: str) -> TokenCache:
180
+ value: object = json.loads(raw)
181
+ if not is_record(value):
182
+ _invalid_structure()
183
+ tokens_value = value.get("tokens")
184
+ if not is_record(tokens_value):
185
+ _invalid_structure()
186
+ version = value.get("version")
187
+ if version == CACHE_VERSION:
188
+ return {"version": CACHE_VERSION, "tokens": _parse_entries(tokens_value, with_host=True)}
189
+ if version == HOST_KEYED_CACHE_VERSION:
190
+ tokens_by_host = _parse_entries(tokens_value, with_host=False)
191
+ return {"version": CACHE_VERSION, "tokens": _from_host_keyed(tokens_by_host)}
192
+ _invalid_structure()
193
+
194
+
195
+ def load_cache() -> TokenCache:
196
+ try:
197
+ with open(cache_file(), encoding="utf-8") as handle:
198
+ return _parse_cache(handle.read())
199
+ except FileNotFoundError:
200
+ return empty_cache()
201
+ except json.JSONDecodeError:
202
+ die(f"token cache {cache_file()} is not valid JSON; move it aside and log in again")
203
+
204
+
205
+ def _save_cache(cache: TokenCache) -> None:
206
+ write_private_file(cache_file(), f"{json.dumps(cache, indent=2)}\n")
207
+
208
+
209
+ T = TypeVar("T")
210
+
211
+
212
+ def with_cache_lock(operation: Callable[[], T]) -> T:
213
+ lock_file = _cache_lock_file()
214
+ os.makedirs(os.path.dirname(lock_file), mode=0o700, exist_ok=True)
215
+ attempts = _cache_lock_attempts()
216
+ retry_ms = _cache_lock_retry_ms()
217
+ for _attempt in range(attempts):
218
+ try:
219
+ descriptor = os.open(lock_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
220
+ except FileExistsError:
221
+ try:
222
+ mtime_ms = os.stat(lock_file).st_mtime * 1000
223
+ if time.time() * 1000 - mtime_ms > CACHE_LOCK_STALE_MS:
224
+ os.unlink(lock_file)
225
+ continue
226
+ except OSError:
227
+ pass
228
+ time.sleep(retry_ms / 1000)
229
+ continue
230
+ try:
231
+ return operation()
232
+ finally:
233
+ os.close(descriptor)
234
+ try:
235
+ os.unlink(lock_file)
236
+ except OSError:
237
+ pass
238
+ die(f"timed out waiting for token cache lock {lock_file}")
239
+
240
+
241
+ def clear_token(owner: TokenOwner) -> bool:
242
+ """Remove the profile's entry, whichever host it was issued for."""
243
+
244
+ def operation() -> bool:
245
+ cache = load_cache()
246
+ if owner["name"] not in cache["tokens"]:
247
+ return False
248
+ del cache["tokens"][owner["name"]]
249
+ _save_cache(cache)
250
+ return True
251
+
252
+ return with_cache_lock(operation)
253
+
254
+
255
+ def clear_refresh_token_if_matches(owner: TokenOwner, rejected_token: str) -> bool:
256
+ def operation() -> bool:
257
+ cache = load_cache()
258
+ entry = entry_for(cache, owner)
259
+ if not entry or entry.get("refresh_token") != rejected_token:
260
+ return False
261
+ for name in _siblings(cache, owner, entry.get("grant")):
262
+ if cache["tokens"][name].get("refresh_token") == rejected_token:
263
+ cache["tokens"][name].pop("refresh_token", None)
264
+ entry.pop("refresh_token", None)
265
+ _save_cache(cache)
266
+ return True
267
+
268
+ return with_cache_lock(operation)
269
+
270
+
271
+ def store_tokens(
272
+ owner: TokenOwner,
273
+ token_response: dict[str, object],
274
+ token_endpoint: str,
275
+ client_id: str | None = None,
276
+ *,
277
+ replace: bool = False,
278
+ ) -> CacheEntry:
279
+ """Write the profile's entry. `replace` starts it afresh (a login or a pasted
280
+ token); otherwise it updates the entry in place (a refresh)."""
281
+
282
+ def operation() -> CacheEntry:
283
+ cache = load_cache()
284
+ previous = None if replace else entry_for(cache, owner)
285
+ expires_in_raw = token_response.get("expires_in")
286
+ if (
287
+ isinstance(expires_in_raw, bool)
288
+ or not isinstance(expires_in_raw, (int, float))
289
+ or not math.isfinite(float(expires_in_raw))
290
+ ):
291
+ expires_in: int | float = 3600
292
+ else:
293
+ expires_in = expires_in_raw
294
+ entry: CacheEntry = previous.copy() if previous is not None else {}
295
+ access_token = token_response.get("access_token")
296
+ if not isinstance(access_token, str):
297
+ die("token response did not include a valid access token")
298
+ entry["host"] = owner["host"]
299
+ entry["access_token"] = access_token
300
+ entry["expires_at"] = int(math.floor(time.time()) + expires_in)
301
+ entry["token_endpoint"] = token_endpoint
302
+ if client_id:
303
+ entry["client_id"] = client_id
304
+ else:
305
+ entry.pop("client_id", None)
306
+ refresh_token = token_response.get("refresh_token")
307
+ if isinstance(refresh_token, str) and refresh_token:
308
+ entry["refresh_token"] = refresh_token
309
+ elif replace:
310
+ entry.pop("refresh_token", None)
311
+ if previous is not None:
312
+ for name in _siblings(cache, owner, previous.get("grant")):
313
+ cache["tokens"][name] = entry.copy()
314
+ cache["tokens"][owner["name"]] = entry
315
+ _save_cache(cache)
316
+ return entry
317
+
318
+ return with_cache_lock(operation)
@@ -14,7 +14,6 @@ LOGOUT_OPTIONS: Options = {
14
14
  def logout_command(argv: list[str]) -> None:
15
15
  values, _positionals = parse_args(argv, options=LOGOUT_OPTIONS)
16
16
  profile = get_profile(load_config(), resolve_profile_name(values["profile"]))
17
- removed = clear_token(profile["host"])
18
- sys.stdout.write(
19
- f"✓ cleared cached token for {profile['host']}\n" if removed else f"no cached token for {profile['host']}\n"
20
- )
17
+ removed = clear_token(profile)
18
+ target = f"profile '{profile['name']}' ({profile['host']})"
19
+ sys.stdout.write(f"✓ cleared cached token for {target}\n" if removed else f"no cached token for {target}\n")