SourceIndex 0.1.2__tar.gz → 0.1.3__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.
- sourceindex-0.1.3/LICENSE +115 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/PKG-INFO +8 -1
- {sourceindex-0.1.2 → sourceindex-0.1.3}/pyproject.toml +17 -1
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/__init__.py +1 -1
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/cli/api_key.py +87 -22
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/cli/commands.py +47 -0
- sourceindex-0.1.3/sourceindex/cli/upgrade.py +143 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/client.py +46 -2
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/server.py +68 -39
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/env.py +6 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/.gitignore +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/GETTING_STARTED.md +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/__init__.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/indexer.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/linerange/__init__.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/linerange/python_ast.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/linerange/treesitter.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/prompts.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/state.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/build/walker.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/claudecode/__init__.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/claudecode/savings.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/claudecode/savings_summary.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/claudecode/statusline.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/cli/__init__.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/cli/__main__.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/cli/install.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/__init__.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/crypto.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/keyring_store.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/lifecycle.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/protocol.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/daemon/store.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/__init__.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/backend.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/cost.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/errors.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/git.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/languages.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/llm.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/log.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/registry.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/lib/timing.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/search/__init__.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/search/experiments.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/search/imports.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/search/passes.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/search/prompts.py +0 -0
- {sourceindex-0.1.2 → sourceindex-0.1.3}/sourceindex/search/roadmap.py +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
SourceIndex — Software License Agreement
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MANTEON PTE. LTD. (Singapore). All rights reserved.
|
|
4
|
+
|
|
5
|
+
SourceIndex is commercial software. It is licensed, not sold. By installing,
|
|
6
|
+
copying, or using it you agree to this Agreement. If you do not agree, do not
|
|
7
|
+
install or use it.
|
|
8
|
+
|
|
9
|
+
1. DEFINITIONS
|
|
10
|
+
|
|
11
|
+
"Licensor" means MANTEON PTE. LTD., a company incorporated in the Republic
|
|
12
|
+
of Singapore. "Software" means the SourceIndex program distributed by the
|
|
13
|
+
Licensor, in source or object form, together with its documentation.
|
|
14
|
+
"You" means the individual or entity exercising rights under this
|
|
15
|
+
Agreement.
|
|
16
|
+
|
|
17
|
+
2. GRANT OF LICENSE
|
|
18
|
+
|
|
19
|
+
Subject to Your compliance with this Agreement, the Licensor grants You a
|
|
20
|
+
limited, non-exclusive, non-transferable, non-sublicensable, revocable
|
|
21
|
+
licence to install and use unmodified copies of the Software for Your own
|
|
22
|
+
internal purposes.
|
|
23
|
+
|
|
24
|
+
No other rights are granted. All rights not expressly granted are reserved
|
|
25
|
+
by the Licensor.
|
|
26
|
+
|
|
27
|
+
3. RESTRICTIONS
|
|
28
|
+
|
|
29
|
+
Except to the extent that this restriction is prohibited by applicable law,
|
|
30
|
+
or expressly permitted by the Licensor in writing, You may not:
|
|
31
|
+
|
|
32
|
+
(a) distribute, publish, sublicense, sell, rent, lease, lend, or otherwise
|
|
33
|
+
make the Software available to any third party;
|
|
34
|
+
(b) modify the Software or create derivative works from it;
|
|
35
|
+
(c) reverse engineer, decompile, or disassemble the Software, or otherwise
|
|
36
|
+
attempt to derive its source code, methods, or prompts, save to the
|
|
37
|
+
extent applicable law confers a non-excludable right to do so for
|
|
38
|
+
interoperability purposes;
|
|
39
|
+
(d) use the Software, or any information derived from it, to develop,
|
|
40
|
+
train, or improve a product or service that competes with it;
|
|
41
|
+
(e) remove, obscure, or alter any copyright, trademark, or other
|
|
42
|
+
proprietary notice; or
|
|
43
|
+
(f) circumvent or disable any licensing, metering, or security mechanism.
|
|
44
|
+
|
|
45
|
+
The Software is distributed in a human-readable form because it is written
|
|
46
|
+
in an interpreted language. That fact does not grant any right beyond
|
|
47
|
+
Clause 2, and does not make the Software open source.
|
|
48
|
+
|
|
49
|
+
4. OWNERSHIP
|
|
50
|
+
|
|
51
|
+
The Software is protected by copyright and other intellectual property
|
|
52
|
+
laws. The Licensor and its licensors retain all right, title, and interest
|
|
53
|
+
in and to the Software, including all prompts, templates, indexes, models
|
|
54
|
+
of operation, and other content contained in it.
|
|
55
|
+
|
|
56
|
+
5. THIRD-PARTY COMPONENTS
|
|
57
|
+
|
|
58
|
+
The Software depends on third-party components distributed under their own
|
|
59
|
+
licences. Those components are licensed to You by their respective owners
|
|
60
|
+
under those licences, not under this Agreement, and nothing here limits
|
|
61
|
+
Your rights under them.
|
|
62
|
+
|
|
63
|
+
6. HOSTED SERVICES
|
|
64
|
+
|
|
65
|
+
The Software may communicate with services operated by the Licensor. Use of
|
|
66
|
+
those services is governed by separate terms and may require a valid
|
|
67
|
+
subscription or API credential. The Licensor may modify, suspend, or
|
|
68
|
+
discontinue those services at any time.
|
|
69
|
+
|
|
70
|
+
7. NO WARRANTY
|
|
71
|
+
|
|
72
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT WARRANTY OF
|
|
73
|
+
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE IMPLIED
|
|
74
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
|
|
75
|
+
SATISFACTORY QUALITY, AND NON-INFRINGEMENT. THE LICENSOR DOES NOT WARRANT
|
|
76
|
+
THAT THE SOFTWARE WILL BE UNINTERRUPTED, ERROR-FREE, OR THAT ITS OUTPUT
|
|
77
|
+
WILL BE ACCURATE OR COMPLETE.
|
|
78
|
+
|
|
79
|
+
8. LIMITATION OF LIABILITY
|
|
80
|
+
|
|
81
|
+
TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE LICENSOR SHALL NOT BE LIABLE
|
|
82
|
+
FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR EXEMPLARY
|
|
83
|
+
DAMAGES, OR FOR ANY LOSS OF PROFITS, REVENUE, DATA, OR GOODWILL, ARISING
|
|
84
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE, EVEN IF ADVISED OF THE
|
|
85
|
+
POSSIBILITY OF SUCH DAMAGES.
|
|
86
|
+
|
|
87
|
+
THE LICENSOR'S TOTAL AGGREGATE LIABILITY ARISING OUT OF OR IN CONNECTION
|
|
88
|
+
WITH THIS AGREEMENT SHALL NOT EXCEED THE GREATER OF (a) THE AMOUNTS PAID BY
|
|
89
|
+
YOU TO THE LICENSOR FOR THE SOFTWARE IN THE TWELVE MONTHS PRECEDING THE
|
|
90
|
+
CLAIM, OR (b) ONE HUNDRED SINGAPORE DOLLARS (SGD 100).
|
|
91
|
+
|
|
92
|
+
Nothing in this Agreement excludes or limits liability that cannot lawfully
|
|
93
|
+
be excluded or limited, including liability for death or personal injury
|
|
94
|
+
caused by negligence, or for fraud or fraudulent misrepresentation.
|
|
95
|
+
|
|
96
|
+
9. TERMINATION
|
|
97
|
+
|
|
98
|
+
This licence terminates automatically if You breach any term of it. On
|
|
99
|
+
termination You must cease all use of the Software and destroy all copies
|
|
100
|
+
in Your possession. Clauses 4, 7, 8, and 10 survive termination.
|
|
101
|
+
|
|
102
|
+
10. GOVERNING LAW AND JURISDICTION
|
|
103
|
+
|
|
104
|
+
This Agreement is governed by the laws of the Republic of Singapore,
|
|
105
|
+
without regard to its conflict of laws rules. The courts of Singapore
|
|
106
|
+
have exclusive jurisdiction over any dispute arising out of or in
|
|
107
|
+
connection with it.
|
|
108
|
+
|
|
109
|
+
11. ENTIRE AGREEMENT
|
|
110
|
+
|
|
111
|
+
This Agreement is the entire agreement between You and the Licensor
|
|
112
|
+
regarding the Software, and supersedes any prior understanding. If any
|
|
113
|
+
provision is held unenforceable, the remainder stays in effect.
|
|
114
|
+
|
|
115
|
+
For licensing enquiries, contact MANTEON PTE. LTD.
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: SourceIndex
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Codebase index for agentic coding
|
|
5
|
+
Author: MANTEON PTE. LTD.
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
5
12
|
Requires-Python: >=3.10
|
|
6
13
|
Requires-Dist: cryptography>=42
|
|
7
14
|
Requires-Dist: keyring>=24
|
|
@@ -4,10 +4,25 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "SourceIndex"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "Codebase index for agentic coding"
|
|
9
9
|
readme = "GETTING_STARTED.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "MANTEON PTE. LTD." }]
|
|
12
|
+
# Proprietary: the published wheel ships readable .py because Python is
|
|
13
|
+
# interpreted, not because the source is licensed for reuse. LicenseRef-* is
|
|
14
|
+
# how PEP 639 spells a licence with no SPDX identifier.
|
|
15
|
+
license = "LicenseRef-Proprietary"
|
|
16
|
+
license-files = ["LICENSE"]
|
|
17
|
+
# No "License ::" classifier: PEP 639 deprecates them, and pairing one with
|
|
18
|
+
# License-Expression is the case where tools are permitted to reject the
|
|
19
|
+
# upload. The expression above is the authoritative declaration.
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 3 - Alpha",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
]
|
|
11
26
|
dependencies = [
|
|
12
27
|
"litellm>=1.50",
|
|
13
28
|
"tree-sitter>=0.23",
|
|
@@ -46,4 +61,5 @@ include = [
|
|
|
46
61
|
"sourceindex/",
|
|
47
62
|
"pyproject.toml",
|
|
48
63
|
"GETTING_STARTED.md",
|
|
64
|
+
"LICENSE",
|
|
49
65
|
]
|
|
@@ -23,6 +23,39 @@ _log = get_logger(__name__)
|
|
|
23
23
|
PERSISTED_KEY_FILENAME = ".env"
|
|
24
24
|
_GLOBAL_API_KEY_MARKER = "_SOURCEINDEX_API_KEY_FROM_GLOBAL"
|
|
25
25
|
|
|
26
|
+
# Records the key the user's *shell* exported, as opposed to one the loaders
|
|
27
|
+
# below put into SOURCEINDEX_API_KEY themselves. The daemon is exec'd with our
|
|
28
|
+
# environment and cannot otherwise tell the two apart — and it must, because
|
|
29
|
+
# only a genuine export outranks the config files it re-reads per RPC. Pinning
|
|
30
|
+
# a file-sourced key at that tier is what used to make `api-key set` a no-op
|
|
31
|
+
# until the daemon restarted.
|
|
32
|
+
#
|
|
33
|
+
# Set at import, which `cli/__init__.py` performs before any loader runs, so
|
|
34
|
+
# the value is the shell's or nothing. `setdefault` preserves what an outer
|
|
35
|
+
# sourceindex process already established.
|
|
36
|
+
_SHELL_API_KEY_VAR = "_SOURCEINDEX_SHELL_API_KEY"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _capture_shell_api_key() -> None:
|
|
40
|
+
"""Snapshot SOURCEINDEX_API_KEY as the shell's, once per process tree.
|
|
41
|
+
|
|
42
|
+
Writes the variable even when there is no key, because the empty value is
|
|
43
|
+
what tells a child process not to snapshot again. The daemon is exec'd by
|
|
44
|
+
a CLI that has already loaded a key out of a config file into the shared
|
|
45
|
+
environment; without that sentinel the daemon's own import would re-badge
|
|
46
|
+
that key as a shell export — the exact bug this indirection exists to fix.
|
|
47
|
+
"""
|
|
48
|
+
os.environ.setdefault(_SHELL_API_KEY_VAR, os.environ.get("SOURCEINDEX_API_KEY", ""))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_capture_shell_api_key()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def shell_api_key() -> str | None:
|
|
55
|
+
"""The key exported into this process tree's environment by the user's
|
|
56
|
+
shell, or None if SOURCEINDEX_API_KEY was only ever loaded from a file."""
|
|
57
|
+
return os.environ.get(_SHELL_API_KEY_VAR) or None
|
|
58
|
+
|
|
26
59
|
# Loose shape check: alphanumerics + `_` / `-`, at least 20 chars, no
|
|
27
60
|
# whitespace or other punctuation. Designed to reject obvious paste
|
|
28
61
|
# mistakes (prompt strings, error messages, paths, URLs) without
|
|
@@ -58,27 +91,30 @@ def _warn_if_provider_key(key: str) -> None:
|
|
|
58
91
|
def _require_api_key(cli_arg: str | None, *, daemon_managed: bool = False) -> str | None:
|
|
59
92
|
"""Resolve the API key from --api-key/env, asserting it exists.
|
|
60
93
|
|
|
61
|
-
``daemon_managed=True``
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
94
|
+
``daemon_managed=True`` returns only an *explicit* key — ``--api-key`` or
|
|
95
|
+
a genuine shell export — and ``None`` otherwise, deferring to the daemon's
|
|
96
|
+
own per-RPC resolution (``daemon/server.py:_resolve_api_key``). Forwarding
|
|
97
|
+
a file-sourced key here would pin it for the call and defeat that, which is
|
|
98
|
+
how a rotated key used to keep getting ignored. Returning ``None`` is also
|
|
99
|
+
what lets hook-triggered subprocesses (post-commit ``sourceindex update``,
|
|
100
|
+
the Claude Code subagent's ``sourceindex search``) work at all: they spawn
|
|
101
|
+
without the user's shell env and rely on the daemon to supply the key.
|
|
68
102
|
"""
|
|
69
103
|
from ..lib.llm import resolve_api_key
|
|
104
|
+
if daemon_managed:
|
|
105
|
+
explicit = _explicit_api_key(cli_arg)
|
|
106
|
+
if explicit:
|
|
107
|
+
_warn_if_provider_key(explicit)
|
|
108
|
+
return explicit
|
|
70
109
|
key = resolve_api_key(cli_arg)
|
|
71
110
|
if key:
|
|
72
111
|
_warn_if_provider_key(key)
|
|
73
112
|
return key
|
|
74
|
-
|
|
75
|
-
return None
|
|
76
|
-
assert key, (
|
|
113
|
+
raise AssertionError(
|
|
77
114
|
"No API key found. Pass --api-key or set SOURCEINDEX_API_KEY "
|
|
78
115
|
"(a sourceindex bearer key from the hosted backend, or a direct "
|
|
79
116
|
"provider key if SOURCEINDEX_BACKEND_URL=\"\" disables the backend)."
|
|
80
117
|
)
|
|
81
|
-
return None
|
|
82
118
|
|
|
83
119
|
|
|
84
120
|
def _prompt_api_key(cli_arg: str | None) -> str:
|
|
@@ -157,18 +193,26 @@ def _global_env_path() -> Path:
|
|
|
157
193
|
return _user_config_dir() / PERSISTED_KEY_FILENAME
|
|
158
194
|
|
|
159
195
|
|
|
160
|
-
def
|
|
161
|
-
|
|
162
|
-
|
|
196
|
+
def _write_env_path(env_path: Path, values: dict[str, str]) -> None:
|
|
197
|
+
"""Write a ``.env``, creating its directory and clamping permissions.
|
|
198
|
+
Every writer goes through here so the hardening can't end up applying to
|
|
199
|
+
only some of them."""
|
|
200
|
+
from ..lib.env import format_env_text
|
|
163
201
|
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
202
|
try:
|
|
165
203
|
env_path.parent.chmod(0o700)
|
|
166
204
|
except OSError:
|
|
167
205
|
pass
|
|
168
|
-
env_path.write_text(
|
|
206
|
+
env_path.write_text(format_env_text(values))
|
|
169
207
|
env_path.chmod(0o600)
|
|
170
208
|
|
|
171
209
|
|
|
210
|
+
def _persist_env_path_var(env_path: Path, key: str, value: str) -> None:
|
|
211
|
+
existing = _parse_env_file(env_path)
|
|
212
|
+
existing[key] = value
|
|
213
|
+
_write_env_path(env_path, existing)
|
|
214
|
+
|
|
215
|
+
|
|
172
216
|
def _persist_env_var(index_dir: Path, key: str, value: str) -> None:
|
|
173
217
|
"""Merge ``key=value`` into ``<index_dir>/.env`` without clobbering other keys.
|
|
174
218
|
|
|
@@ -183,6 +227,16 @@ def _persist_env_var(index_dir: Path, key: str, value: str) -> None:
|
|
|
183
227
|
_persist_env_path_var(index_dir / PERSISTED_KEY_FILENAME, key, value)
|
|
184
228
|
|
|
185
229
|
|
|
230
|
+
def _clear_env_path_var(env_path: Path, key: str) -> bool:
|
|
231
|
+
"""Remove one variable from a plaintext .env, leaving the rest intact.
|
|
232
|
+
Returns whether it was there."""
|
|
233
|
+
existing = _parse_env_file(env_path)
|
|
234
|
+
if existing.pop(key, None) is None:
|
|
235
|
+
return False
|
|
236
|
+
_write_env_path(env_path, existing)
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
|
|
186
240
|
def _persist_api_key(index_dir: Path, key: str) -> None:
|
|
187
241
|
if not _is_plausible_api_key(key):
|
|
188
242
|
# Skip (don't abort) an explicitly-provided key whose shape is unusual but
|
|
@@ -248,6 +302,14 @@ def _load_env_path(path: Path, *, source: str | None = None) -> None:
|
|
|
248
302
|
os.environ.pop(_GLOBAL_API_KEY_MARKER, None)
|
|
249
303
|
|
|
250
304
|
|
|
305
|
+
def _explicit_api_key(cli_arg: str | None) -> str | None:
|
|
306
|
+
"""The key this invocation was *given*: ``--api-key``, else a genuine
|
|
307
|
+
shell export. A key merely loaded from a config file is not explicit —
|
|
308
|
+
the daemon reads those files itself and must stay free to re-resolve
|
|
309
|
+
them per RPC."""
|
|
310
|
+
return (cli_arg or "").strip() or shell_api_key()
|
|
311
|
+
|
|
312
|
+
|
|
251
313
|
def _load_global_env() -> None:
|
|
252
314
|
_load_env_path(_global_env_path(), source="global")
|
|
253
315
|
|
|
@@ -259,14 +321,17 @@ def _api_key_from_global_fallback(cli_arg: str | None) -> bool:
|
|
|
259
321
|
def _load_persisted_env(repo_root: Path) -> None:
|
|
260
322
|
"""Merge plaintext .sourceindex/.env into os.environ (pre-existing vars
|
|
261
323
|
win, standard dotenv semantics), then fall back to the user-level config.
|
|
262
|
-
|
|
263
|
-
|
|
324
|
+
|
|
325
|
+
An encrypted repo has no readable repo-level .env — the daemon owns it —
|
|
326
|
+
but the user-level config is still ours to read, and skipping it used to
|
|
327
|
+
hide the global key from every indexed repo. Loading it is safe now that
|
|
328
|
+
file-sourced keys are marked and never forwarded over RPC, so they can no
|
|
329
|
+
longer shadow the per-repo key the daemon holds."""
|
|
264
330
|
from ..daemon import IndexDir
|
|
265
331
|
os.environ.pop(_GLOBAL_API_KEY_MARKER, None)
|
|
266
332
|
idx = IndexDir.for_repo(repo_root)
|
|
267
|
-
if idx.is_encrypted_layout():
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
_load_env_path(env_path)
|
|
333
|
+
if not idx.is_encrypted_layout():
|
|
334
|
+
env_path = idx.path / PERSISTED_KEY_FILENAME
|
|
335
|
+
if env_path.is_file():
|
|
336
|
+
_load_env_path(env_path)
|
|
272
337
|
_load_global_env()
|
|
@@ -14,6 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
|
|
15
15
|
from ..lib.log import get_logger
|
|
16
16
|
from .api_key import (
|
|
17
|
+
PERSISTED_KEY_FILENAME,
|
|
17
18
|
_api_key_from_global_fallback,
|
|
18
19
|
_persist_api_key,
|
|
19
20
|
_persist_global_api_key,
|
|
@@ -30,6 +31,7 @@ from .install import (
|
|
|
30
31
|
_ensure_gitignored,
|
|
31
32
|
_install_hooks,
|
|
32
33
|
)
|
|
34
|
+
from .upgrade import schedule_auto_upgrade
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
def _parse_languages(value: str | None):
|
|
@@ -109,6 +111,10 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
109
111
|
|
|
110
112
|
api_key_is_global_fallback = _api_key_from_global_fallback(args.api_key)
|
|
111
113
|
api_key = _prompt_api_key(args.api_key)
|
|
114
|
+
# apply_remote_config and the preflight below read the key from the
|
|
115
|
+
# environment, so it has to go there. The daemon we spawn inherits this,
|
|
116
|
+
# but only treats _SHELL_API_KEY_VAR as an export, so a prompted key
|
|
117
|
+
# cannot masquerade as one.
|
|
112
118
|
os.environ["SOURCEINDEX_API_KEY"] = api_key
|
|
113
119
|
if not api_key_is_global_fallback:
|
|
114
120
|
_persist_global_api_key(api_key)
|
|
@@ -176,6 +182,8 @@ def cmd_update(args: argparse.Namespace) -> None:
|
|
|
176
182
|
from ..lib.backend import apply_remote_config
|
|
177
183
|
from ..lib.llm import llm_error_to_systemexit
|
|
178
184
|
|
|
185
|
+
schedule_auto_upgrade()
|
|
186
|
+
|
|
179
187
|
repo_root = Path(args.repo_root).resolve()
|
|
180
188
|
idx = IndexDir.for_repo(repo_root)
|
|
181
189
|
index_dir = idx.path
|
|
@@ -208,6 +216,42 @@ def cmd_update(args: argparse.Namespace) -> None:
|
|
|
208
216
|
)
|
|
209
217
|
|
|
210
218
|
|
|
219
|
+
def _clear_local_api_key_override(repo_root: Path) -> None:
|
|
220
|
+
"""Drop any per-repo key for ``repo_root`` after a global set.
|
|
221
|
+
|
|
222
|
+
A per-repo key outranks the user-level one in both resolvers, so a global
|
|
223
|
+
`api-key set` run from an indexed repo would otherwise report success and
|
|
224
|
+
change nothing there. Encrypted stores can only be rewritten by the daemon;
|
|
225
|
+
plaintext ones we edit directly. A repo with no index has neither.
|
|
226
|
+
"""
|
|
227
|
+
from ..daemon import Client, IndexDir
|
|
228
|
+
from .api_key import _clear_env_path_var
|
|
229
|
+
|
|
230
|
+
idx = IndexDir.for_repo(repo_root)
|
|
231
|
+
if idx.is_encrypted_layout():
|
|
232
|
+
try:
|
|
233
|
+
cleared = bool(Client(repo_root).set_api_key(scope="global").get("cleared_local"))
|
|
234
|
+
except Exception as e:
|
|
235
|
+
_log.warning(
|
|
236
|
+
f"Saved the global key, but could not reach this repository's "
|
|
237
|
+
f"daemon to clear a per-repo override ({e}). If searches keep "
|
|
238
|
+
f"failing with the old key, run `sourceindex api-key set --local`.",
|
|
239
|
+
extra={"event": "api_key.local_override_clear_failed"},
|
|
240
|
+
)
|
|
241
|
+
return
|
|
242
|
+
else:
|
|
243
|
+
cleared = _clear_env_path_var(
|
|
244
|
+
idx.path / PERSISTED_KEY_FILENAME, "SOURCEINDEX_API_KEY"
|
|
245
|
+
)
|
|
246
|
+
if cleared:
|
|
247
|
+
_log.info(
|
|
248
|
+
"Cleared this repository's per-repo API key so the global one "
|
|
249
|
+
"applies. Run `sourceindex api-key set --local` to pin a key to "
|
|
250
|
+
"this repository again.",
|
|
251
|
+
extra={"event": "api_key.local_override_cleared"},
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
211
255
|
def cmd_api_key_set(args: argparse.Namespace) -> None:
|
|
212
256
|
from ..daemon import Client, IndexDir
|
|
213
257
|
|
|
@@ -226,6 +270,7 @@ def cmd_api_key_set(args: argparse.Namespace) -> None:
|
|
|
226
270
|
|
|
227
271
|
if not local_key:
|
|
228
272
|
_persist_global_api_key(api_key)
|
|
273
|
+
_clear_local_api_key_override(Path(args.repo_root).resolve())
|
|
229
274
|
return
|
|
230
275
|
|
|
231
276
|
repo_root = Path(args.repo_root).resolve()
|
|
@@ -300,6 +345,8 @@ def cmd_search(args: argparse.Namespace) -> None:
|
|
|
300
345
|
from ..lib.llm import llm_error_to_systemexit
|
|
301
346
|
from ..search import search_index
|
|
302
347
|
|
|
348
|
+
schedule_auto_upgrade()
|
|
349
|
+
|
|
303
350
|
repo_root = Path(args.repo_root).resolve()
|
|
304
351
|
idx = IndexDir.for_repo(repo_root, args.index_dir)
|
|
305
352
|
index_dir = idx.path
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Background self-upgrade for the installed sourceindex package.
|
|
2
|
+
|
|
3
|
+
``sourceindex search`` and ``sourceindex update`` call
|
|
4
|
+
:func:`schedule_auto_upgrade` on every invocation, which defers the work to
|
|
5
|
+
interpreter exit; at most once per :data:`CHECK_INTERVAL_S` it then spawns
|
|
6
|
+
the package manager that owns this install (pip / pipx / ``uv tool``) fully
|
|
7
|
+
detached to pull the latest release. The finished invocation is unaffected —
|
|
8
|
+
every CLI run is a fresh interpreter, and a live daemon is recycled by the
|
|
9
|
+
version handshake in ``daemon/client.py`` the next time a newer client talks
|
|
10
|
+
to it.
|
|
11
|
+
|
|
12
|
+
Hard rules:
|
|
13
|
+
|
|
14
|
+
- Never write to stdout/stderr. Search's stdout is relayed verbatim by the
|
|
15
|
+
subagent, so any stray line would corrupt the roadmap completion signal.
|
|
16
|
+
The package manager's output goes to ``auto-upgrade.log`` in the user
|
|
17
|
+
config dir instead.
|
|
18
|
+
- Never raise, and never block: an upgrade problem must not break or slow
|
|
19
|
+
the search that triggered it.
|
|
20
|
+
|
|
21
|
+
``SOURCEINDEX_AUTO_UPGRADE=0`` disables the whole mechanism (evals and CI
|
|
22
|
+
should set it). Installs that did not come from a package index — editable
|
|
23
|
+
checkouts, local wheels, VCS installs, anything with a ``direct_url.json``
|
|
24
|
+
— are never touched: PyPI is not their source, so an automatic upgrade
|
|
25
|
+
would silently switch sources.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import atexit
|
|
31
|
+
import fcntl
|
|
32
|
+
import importlib.metadata
|
|
33
|
+
import os
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
|
|
39
|
+
from ..lib.log import get_logger
|
|
40
|
+
from .api_key import _user_config_dir
|
|
41
|
+
|
|
42
|
+
_log = get_logger(__name__)
|
|
43
|
+
|
|
44
|
+
CHECK_INTERVAL_S = 3600.0
|
|
45
|
+
STAMP_FILENAME = "auto-upgrade.stamp"
|
|
46
|
+
LOG_FILENAME = "auto-upgrade.log"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def schedule_auto_upgrade() -> None:
|
|
50
|
+
"""Arrange for the upgrade to be spawned once this command is finished.
|
|
51
|
+
|
|
52
|
+
Registered rather than spawned on the spot, because pip upgrades by
|
|
53
|
+
deleting the installed package and unpacking the new one — and this
|
|
54
|
+
process is still reading from it. Most of the package's imports are
|
|
55
|
+
deferred into function bodies, so a long command (a post-commit
|
|
56
|
+
``update`` reindexing a large diff) has modules left to load minutes
|
|
57
|
+
after startup, and ``search`` execs a whole new interpreter from that
|
|
58
|
+
same install partway through. Overwriting it underneath either one risks
|
|
59
|
+
a half-old, half-new import or an exec of a momentarily incomplete
|
|
60
|
+
package. By interpreter exit there is nothing left to disturb.
|
|
61
|
+
|
|
62
|
+
Deferring also keeps pip off the CPU and network while the command is
|
|
63
|
+
doing its own work.
|
|
64
|
+
|
|
65
|
+
atexit runs on SystemExit too, so a command that failed still upgrades —
|
|
66
|
+
the newer version may be exactly the fix. It does not run on a signal or
|
|
67
|
+
``os._exit``, which only costs a skipped cycle.
|
|
68
|
+
"""
|
|
69
|
+
atexit.register(maybe_spawn_auto_upgrade)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def maybe_spawn_auto_upgrade() -> None:
|
|
73
|
+
"""Spawn a detached best-effort upgrade, throttled to once per hour."""
|
|
74
|
+
try:
|
|
75
|
+
_maybe_spawn()
|
|
76
|
+
except Exception as e: # noqa: BLE001 — must never break the caller
|
|
77
|
+
_log.debug(
|
|
78
|
+
"auto-upgrade skipped", extra={"event": "upgrade.skipped", "error": str(e)}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _disabled() -> bool:
|
|
83
|
+
return os.environ.get("SOURCEINDEX_AUTO_UPGRADE", "").strip().lower() in (
|
|
84
|
+
"0", "false", "no", "off",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _upgrade_command() -> list[str] | None:
|
|
89
|
+
"""Pick the upgrade command for this install, or ``None`` to leave it alone."""
|
|
90
|
+
try:
|
|
91
|
+
dist = importlib.metadata.distribution("sourceindex")
|
|
92
|
+
except importlib.metadata.PackageNotFoundError:
|
|
93
|
+
return None # running from a checkout that was never installed
|
|
94
|
+
if dist.read_text("direct_url.json"):
|
|
95
|
+
return None # editable / local / VCS install — not ours to upgrade
|
|
96
|
+
exe = sys.executable
|
|
97
|
+
if "/uv/tools/" in exe and shutil.which("uv"):
|
|
98
|
+
return ["uv", "tool", "upgrade", "sourceindex"]
|
|
99
|
+
if "/pipx/venvs/" in exe and shutil.which("pipx"):
|
|
100
|
+
return ["pipx", "upgrade", "sourceindex"]
|
|
101
|
+
return [exe, "-m", "pip", "install", "--upgrade", "--quiet", "sourceindex"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _maybe_spawn() -> None:
|
|
105
|
+
if _disabled():
|
|
106
|
+
return
|
|
107
|
+
cmd = _upgrade_command()
|
|
108
|
+
if cmd is None:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
config_dir = _user_config_dir()
|
|
112
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
stamp = config_dir / STAMP_FILENAME
|
|
114
|
+
fd = os.open(stamp, os.O_RDWR | os.O_CREAT, 0o644)
|
|
115
|
+
try:
|
|
116
|
+
try:
|
|
117
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
118
|
+
except OSError:
|
|
119
|
+
return # a concurrent invocation is claiming this window
|
|
120
|
+
st = os.fstat(fd)
|
|
121
|
+
# A zero-size stamp is the O_CREAT of a claimant that died before
|
|
122
|
+
# writing — its fresh mtime must not count as a completed claim.
|
|
123
|
+
if st.st_size and time.time() - st.st_mtime < CHECK_INTERVAL_S:
|
|
124
|
+
return
|
|
125
|
+
os.ftruncate(fd, 0)
|
|
126
|
+
os.write(fd, f"{time.time():.0f}\n".encode())
|
|
127
|
+
|
|
128
|
+
with open(config_dir / LOG_FILENAME, "wb") as out:
|
|
129
|
+
out.write((" ".join(cmd) + "\n").encode())
|
|
130
|
+
out.flush()
|
|
131
|
+
subprocess.Popen(
|
|
132
|
+
cmd,
|
|
133
|
+
stdin=subprocess.DEVNULL,
|
|
134
|
+
stdout=out,
|
|
135
|
+
stderr=out,
|
|
136
|
+
start_new_session=True,
|
|
137
|
+
)
|
|
138
|
+
_log.info(
|
|
139
|
+
"Spawned background upgrade: %s", " ".join(cmd),
|
|
140
|
+
extra={"event": "upgrade.spawned", "cmd": cmd},
|
|
141
|
+
)
|
|
142
|
+
finally:
|
|
143
|
+
os.close(fd)
|
|
@@ -10,6 +10,10 @@ from typing import Any
|
|
|
10
10
|
|
|
11
11
|
from . import lifecycle
|
|
12
12
|
from .protocol import E_SERVER, RpcError, encode_frame, make_request, read_frame_sync
|
|
13
|
+
from .. import __version__
|
|
14
|
+
from ..lib.log import get_logger
|
|
15
|
+
|
|
16
|
+
_log = get_logger(__name__)
|
|
13
17
|
|
|
14
18
|
|
|
15
19
|
def _drop_none(**kwargs: Any) -> dict[str, Any]:
|
|
@@ -35,6 +39,7 @@ class Client:
|
|
|
35
39
|
self.allow_dump_on_spawn = allow_dump_on_spawn
|
|
36
40
|
self.repo_hash = lifecycle.repo_hash_for(self._main_worktree())
|
|
37
41
|
self._next_id = 1
|
|
42
|
+
self._version_checked = False
|
|
38
43
|
|
|
39
44
|
def _main_worktree(self) -> Path:
|
|
40
45
|
from ..lib.git import resolve_main_worktree
|
|
@@ -49,6 +54,7 @@ class Client:
|
|
|
49
54
|
|
|
50
55
|
def _ensure_running(self) -> None:
|
|
51
56
|
if lifecycle.is_daemon_running(self.repo_root):
|
|
57
|
+
self._recycle_if_stale()
|
|
52
58
|
return
|
|
53
59
|
if not self.auto_spawn:
|
|
54
60
|
raise FileNotFoundError(
|
|
@@ -57,6 +63,38 @@ class Client:
|
|
|
57
63
|
)
|
|
58
64
|
lifecycle.spawn_daemon(self.repo_root, allow_dump=self.allow_dump_on_spawn)
|
|
59
65
|
|
|
66
|
+
def _recycle_if_stale(self) -> None:
|
|
67
|
+
"""Restart a running daemon that predates the installed package.
|
|
68
|
+
|
|
69
|
+
The background auto-upgrade (``cli/upgrade.py``) swaps the code on
|
|
70
|
+
disk, but a live daemon keeps serving whatever it loaded at startup.
|
|
71
|
+
Ping reports that version; when it differs from ours, restart the
|
|
72
|
+
daemon so RPCs run the same code as this CLI. Checked once per
|
|
73
|
+
Client. Admin clients skip (ping is a user-socket method), and so
|
|
74
|
+
does a client that may not spawn — it must not kill a daemon it
|
|
75
|
+
cannot replace.
|
|
76
|
+
"""
|
|
77
|
+
if self._version_checked or self.admin or not self.auto_spawn:
|
|
78
|
+
return
|
|
79
|
+
self._version_checked = True
|
|
80
|
+
try:
|
|
81
|
+
info = self._call("ping")
|
|
82
|
+
except Exception:
|
|
83
|
+
return # let the real RPC surface any failure
|
|
84
|
+
daemon_version = info.get("version")
|
|
85
|
+
if daemon_version == __version__:
|
|
86
|
+
return
|
|
87
|
+
_log.info(
|
|
88
|
+
"Daemon runs sourceindex %s but %s is installed; restarting it",
|
|
89
|
+
daemon_version, __version__,
|
|
90
|
+
extra={"event": "daemon.version_recycle", "daemon_version": daemon_version},
|
|
91
|
+
)
|
|
92
|
+
lifecycle.stop_daemon(self.repo_root)
|
|
93
|
+
lifecycle.spawn_daemon(
|
|
94
|
+
self.repo_root,
|
|
95
|
+
allow_dump=bool(info.get("allow_dump")) or self.allow_dump_on_spawn,
|
|
96
|
+
)
|
|
97
|
+
|
|
60
98
|
def _call(self, method: str, params: dict | None = None) -> Any:
|
|
61
99
|
self._ensure_running()
|
|
62
100
|
path = self._socket_path()
|
|
@@ -129,8 +167,14 @@ class Client:
|
|
|
129
167
|
def usage(self) -> dict:
|
|
130
168
|
return self._call("usage")
|
|
131
169
|
|
|
132
|
-
def set_api_key(self, api_key: str) -> dict:
|
|
133
|
-
|
|
170
|
+
def set_api_key(self, api_key: str | None = None, *, scope: str = "local") -> dict:
|
|
171
|
+
params: dict = {"scope": scope}
|
|
172
|
+
# A global set needs no secret over the wire — the client has already
|
|
173
|
+
# written the user-level config and only wants the per-repo override
|
|
174
|
+
# out of the way.
|
|
175
|
+
if scope == "local":
|
|
176
|
+
params["api_key"] = api_key
|
|
177
|
+
return self._call("set_api_key", params)
|
|
134
178
|
|
|
135
179
|
def dump_all(self, out_dir: Path) -> dict:
|
|
136
180
|
return self._call("dump_all", {"out_dir": str(Path(out_dir).resolve())})
|
|
@@ -26,6 +26,7 @@ from .protocol import (
|
|
|
26
26
|
read_frame_async,
|
|
27
27
|
)
|
|
28
28
|
from .store import EncryptedStore
|
|
29
|
+
from .. import __version__
|
|
29
30
|
from ..lib.log import log_error
|
|
30
31
|
|
|
31
32
|
|
|
@@ -81,10 +82,13 @@ class Daemon:
|
|
|
81
82
|
self._last_activity = time.monotonic()
|
|
82
83
|
self._shutdown_evt: asyncio.Event | None = None
|
|
83
84
|
self._repo_was_deleted = False
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
85
|
+
# Only a key the user's shell exported outranks the persisted files we
|
|
86
|
+
# re-read per RPC. Reading SOURCEINDEX_API_KEY directly would not do:
|
|
87
|
+
# we are exec'd from a CLI that may have loaded that value out of a
|
|
88
|
+
# config file itself, and pinning such a key at the top tier is what
|
|
89
|
+
# used to survive every rotation.
|
|
90
|
+
from ..cli.api_key import shell_api_key
|
|
91
|
+
self._exported_api_key = shell_api_key()
|
|
88
92
|
self._api_key_source: str | None = None
|
|
89
93
|
self._api_key_fingerprint: str | None = None
|
|
90
94
|
self._user_handlers: dict[str, Callable[[dict], Awaitable[dict]]] = {}
|
|
@@ -141,29 +145,40 @@ class Daemon:
|
|
|
141
145
|
lifecycle.pidfile(self.repo_hash).unlink(missing_ok=True)
|
|
142
146
|
lifecycle.cleanup_stale_files(self.repo_hash)
|
|
143
147
|
|
|
144
|
-
def
|
|
148
|
+
def _read_store_env(self) -> dict[str, str]:
|
|
149
|
+
"""Decrypt and parse the store's ``.env``. A store without one, or one
|
|
150
|
+
we cannot read, is reported as empty — every caller treats a missing
|
|
151
|
+
setting the same way, and none of them can act on a decrypt failure."""
|
|
145
152
|
if not self.store.exists(".env"):
|
|
146
|
-
return
|
|
153
|
+
return {}
|
|
147
154
|
try:
|
|
148
155
|
text = self.store.read_text(".env")
|
|
149
156
|
except Exception as e:
|
|
150
|
-
_log.warning("could not
|
|
151
|
-
return
|
|
157
|
+
_log.warning("could not read encrypted .env: %s", e)
|
|
158
|
+
return {}
|
|
152
159
|
from ..lib.env import parse_env_text
|
|
153
|
-
|
|
160
|
+
return parse_env_text(text)
|
|
161
|
+
|
|
162
|
+
def _write_store_env(self, env_vars: dict[str, str]) -> None:
|
|
163
|
+
from ..lib.env import format_env_text
|
|
164
|
+
self.store.write_text(".env", format_env_text(env_vars))
|
|
165
|
+
|
|
166
|
+
def _load_env_from_store(self) -> None:
|
|
167
|
+
for k, v in self._read_store_env().items():
|
|
154
168
|
if k and k not in os.environ:
|
|
155
169
|
os.environ[k] = v
|
|
156
170
|
|
|
157
171
|
def _api_key_from_store(self) -> str | None:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
return self._read_store_env().get("SOURCEINDEX_API_KEY") or None
|
|
173
|
+
|
|
174
|
+
def _clear_store_api_key(self) -> bool:
|
|
175
|
+
"""Drop the per-repo key override, leaving the store's other settings
|
|
176
|
+
alone. Returns whether one was actually there."""
|
|
177
|
+
env_vars = self._read_store_env()
|
|
178
|
+
if env_vars.pop("SOURCEINDEX_API_KEY", None) is None:
|
|
179
|
+
return False
|
|
180
|
+
self._write_store_env(env_vars)
|
|
181
|
+
return True
|
|
167
182
|
|
|
168
183
|
def _resolve_api_key(self) -> tuple[str | None, str]:
|
|
169
184
|
"""Resolve the effective API key from scratch, highest precedence
|
|
@@ -385,6 +400,10 @@ class Daemon:
|
|
|
385
400
|
"ok": True,
|
|
386
401
|
"repo_hash": self.repo_hash,
|
|
387
402
|
"allow_dump": self.allow_dump,
|
|
403
|
+
# The code loaded at startup, not what is on disk — the client
|
|
404
|
+
# compares this against its own version to recycle a daemon that
|
|
405
|
+
# predates a background auto-upgrade.
|
|
406
|
+
"version": __version__,
|
|
388
407
|
"idle_for_s": time.monotonic() - self._last_activity,
|
|
389
408
|
}
|
|
390
409
|
|
|
@@ -486,21 +505,34 @@ class Daemon:
|
|
|
486
505
|
return {"budget": budget}
|
|
487
506
|
|
|
488
507
|
async def _h_set_api_key(self, params: dict) -> dict:
|
|
489
|
-
|
|
490
|
-
if not isinstance(api_key, str) or not api_key:
|
|
491
|
-
raise RpcError(E_INVALID_PARAMS, "api_key is required")
|
|
492
|
-
if "\n" in api_key or "\r" in api_key:
|
|
493
|
-
raise RpcError(E_INVALID_PARAMS, "api_key must be a single line")
|
|
508
|
+
"""Apply an ``api-key set``.
|
|
494
509
|
|
|
495
|
-
|
|
510
|
+
``scope="local"`` writes the key into this repo's store as a per-repo
|
|
511
|
+
override. ``scope="global"`` means the client already wrote the
|
|
512
|
+
user-level config and we only need to stand out of the way: clear any
|
|
513
|
+
per-repo override, which would otherwise outrank it and make the
|
|
514
|
+
global command a silent no-op on every indexed repo.
|
|
515
|
+
"""
|
|
516
|
+
scope = params.get("scope") or "local"
|
|
517
|
+
if scope not in ("local", "global"):
|
|
518
|
+
raise RpcError(E_INVALID_PARAMS, "scope must be 'local' or 'global'")
|
|
519
|
+
|
|
520
|
+
api_key = params.get("api_key")
|
|
521
|
+
cleared_local = False
|
|
522
|
+
if scope == "local":
|
|
523
|
+
if not isinstance(api_key, str) or not api_key:
|
|
524
|
+
raise RpcError(E_INVALID_PARAMS, "api_key is required")
|
|
525
|
+
if "\n" in api_key or "\r" in api_key:
|
|
526
|
+
raise RpcError(E_INVALID_PARAMS, "api_key must be a single line")
|
|
527
|
+
|
|
528
|
+
env_vars = self._read_store_env()
|
|
529
|
+
env_vars["SOURCEINDEX_API_KEY"] = api_key
|
|
530
|
+
self._write_store_env(env_vars)
|
|
531
|
+
else:
|
|
532
|
+
# The client narrates this one to the user off `cleared_local`;
|
|
533
|
+
# duplicating it here would double-count the event in the logs.
|
|
534
|
+
cleared_local = self._clear_store_api_key()
|
|
496
535
|
|
|
497
|
-
env_vars = {}
|
|
498
|
-
if self.store.exists(".env"):
|
|
499
|
-
env_vars = parse_env_text(self.store.read_text(".env"))
|
|
500
|
-
env_vars["SOURCEINDEX_API_KEY"] = api_key
|
|
501
|
-
self.store.write_text(
|
|
502
|
-
".env", "".join(f"{k}={v}\n" for k, v in env_vars.items())
|
|
503
|
-
)
|
|
504
536
|
# An explicit `api-key set` is a deliberate act and outranks whatever
|
|
505
537
|
# was exported into the environment we were launched with — otherwise
|
|
506
538
|
# a stale export would silently shadow the key just written, which is
|
|
@@ -508,14 +540,14 @@ class Daemon:
|
|
|
508
540
|
if self._exported_api_key and self._exported_api_key != api_key:
|
|
509
541
|
_log.warning(
|
|
510
542
|
"Superseding the SOURCEINDEX_API_KEY exported in the daemon's "
|
|
511
|
-
"environment (%s)
|
|
512
|
-
"
|
|
513
|
-
_key_fingerprint(self._exported_api_key),
|
|
543
|
+
"environment (%s). Remove that export so every SourceIndex "
|
|
544
|
+
"process agrees on one key.",
|
|
545
|
+
_key_fingerprint(self._exported_api_key),
|
|
514
546
|
extra={"event": "api_key.export_superseded"},
|
|
515
547
|
)
|
|
516
548
|
self._exported_api_key = None
|
|
517
549
|
self._refresh_api_key()
|
|
518
|
-
return {"updated": True}
|
|
550
|
+
return {"updated": True, "cleared_local": cleared_local}
|
|
519
551
|
|
|
520
552
|
def _register_admin_handlers(self) -> None:
|
|
521
553
|
self._admin_handlers = {
|
|
@@ -545,10 +577,7 @@ class Daemon:
|
|
|
545
577
|
return {"text": self.store.read_text(f"details/{rel}.md")}
|
|
546
578
|
|
|
547
579
|
async def _h_read_env(self, params: dict) -> dict:
|
|
548
|
-
|
|
549
|
-
return {"vars": {}}
|
|
550
|
-
from ..lib.env import parse_env_text
|
|
551
|
-
return {"vars": parse_env_text(self.store.read_text(".env"))}
|
|
580
|
+
return {"vars": self._read_store_env()}
|
|
552
581
|
|
|
553
582
|
async def _h_shutdown(self, params: dict) -> dict:
|
|
554
583
|
self._request_shutdown()
|
|
@@ -28,3 +28,9 @@ def parse_env_text(contents: str) -> dict[str, str]:
|
|
|
28
28
|
k, _, v = line.partition("=")
|
|
29
29
|
out[k.strip()] = v.strip()
|
|
30
30
|
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_env_text(values: dict[str, str]) -> str:
|
|
34
|
+
"""Serialize ``{key: value}`` back to ``.env``-style text. Round-trip
|
|
35
|
+
partner of ``parse_env_text`` — kept beside it so the two cannot drift."""
|
|
36
|
+
return "".join(f"{k}={v}\n" for k, v in values.items())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|