memorysync-cli 1.0.2__tar.gz → 1.0.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.
Files changed (25) hide show
  1. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/PKG-INFO +27 -1
  2. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/README.md +26 -0
  3. memorysync_cli-1.0.3/src/memorysync_cli/_version.py +1 -0
  4. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/commands/admin.py +12 -3
  5. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/config.py +43 -19
  6. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/credentials.py +67 -42
  7. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/errors.py +23 -1
  8. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/output.py +8 -0
  9. memorysync_cli-1.0.2/src/memorysync_cli/_version.py +0 -1
  10. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/.gitignore +0 -0
  11. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/LICENSE +0 -0
  12. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/pyproject.toml +0 -0
  13. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/__init__.py +0 -0
  14. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/__main__.py +0 -0
  15. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/args.py +0 -0
  16. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/commands/__init__.py +0 -0
  17. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/commands/init.py +0 -0
  18. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/commands/memory.py +0 -0
  19. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/commands/source.py +0 -0
  20. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/commands/tooling.py +0 -0
  21. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/completions.py +0 -0
  22. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/http.py +0 -0
  23. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/main.py +0 -0
  24. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/registry.json +0 -0
  25. {memorysync_cli-1.0.2 → memorysync_cli-1.0.3}/src/memorysync_cli/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memorysync-cli
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: MemorySync from your terminal. Zero dependencies.
5
5
  Project-URL: Documentation, https://docs.memorysync.io/cli
6
6
  Project-URL: Homepage, https://memorysync.io/cli
@@ -42,6 +42,32 @@ externally-managed Pythons — Homebrew and most Linux distributions — with
42
42
 
43
43
  Requires Python 3.9 or newer.
44
44
 
45
+ ### `memorysync: command not found` after installing
46
+
47
+ The install worked; your shell cannot see the directory `pipx` puts binaries in.
48
+ Run `pipx ensurepath`, then reopen the terminal. Or invoke it by full path —
49
+ `pipx list` prints the location.
50
+
51
+ To skip `PATH` entirely:
52
+
53
+ ```bash
54
+ pipx run --spec memorysync-cli memorysync <command>
55
+ ```
56
+
57
+ `--spec` is needed because `pipx run memorysync-cli` looks for a console script
58
+ named after the package, and this package's scripts are `memorysync` and `msync`.
59
+
60
+ ### Both CLIs installed? Only one `memorysync` can win
61
+
62
+ The npm package installs the same two command names, so whichever directory comes
63
+ first on `PATH` answers. To run *this* one specifically, use the full path from
64
+ `pipx list`, or `pipx run --spec` as above.
65
+
66
+ `python -m memorysync_cli` works only when the package is installed into that
67
+ interpreter with `pip install`. After a `pipx` install it will report
68
+ `No module named memorysync_cli`, because pipx deliberately isolates the package
69
+ in its own virtualenv.
70
+
45
71
  ## Getting started
46
72
 
47
73
  ```bash
@@ -17,6 +17,32 @@ externally-managed Pythons — Homebrew and most Linux distributions — with
17
17
 
18
18
  Requires Python 3.9 or newer.
19
19
 
20
+ ### `memorysync: command not found` after installing
21
+
22
+ The install worked; your shell cannot see the directory `pipx` puts binaries in.
23
+ Run `pipx ensurepath`, then reopen the terminal. Or invoke it by full path —
24
+ `pipx list` prints the location.
25
+
26
+ To skip `PATH` entirely:
27
+
28
+ ```bash
29
+ pipx run --spec memorysync-cli memorysync <command>
30
+ ```
31
+
32
+ `--spec` is needed because `pipx run memorysync-cli` looks for a console script
33
+ named after the package, and this package's scripts are `memorysync` and `msync`.
34
+
35
+ ### Both CLIs installed? Only one `memorysync` can win
36
+
37
+ The npm package installs the same two command names, so whichever directory comes
38
+ first on `PATH` answers. To run *this* one specifically, use the full path from
39
+ `pipx list`, or `pipx run --spec` as above.
40
+
41
+ `python -m memorysync_cli` works only when the package is installed into that
42
+ interpreter with `pip install`. After a `pipx` install it will report
43
+ `No module named memorysync_cli`, because pipx deliberately isolates the package
44
+ in its own virtualenv.
45
+
20
46
  ## Getting started
21
47
 
22
48
  ```bash
@@ -0,0 +1 @@
1
+ __version__ = "1.0.3"
@@ -126,7 +126,7 @@ def whoami(ctx: dict) -> dict:
126
126
  "user": settings.get("user"),
127
127
  "project": settings.get("project"),
128
128
  "plan": plan.get("plan") or plan.get("name"),
129
- "credential_source": credentials.describe_storage(),
129
+ "credential_source": credentials.describe_storage(settings["profile_name"]),
130
130
  }
131
131
 
132
132
  def render() -> str:
@@ -168,14 +168,23 @@ def doctor(ctx: dict) -> dict:
168
168
  )
169
169
  add("cli", True, f"memorysync-cli {__version__}")
170
170
  add("config", True, str(config_module.config_path()))
171
- add("credential store", True, credentials.describe_storage())
171
+ add("credential store", True, credentials.describe_storage(settings["profile_name"]))
172
172
 
173
173
  key = credentials.read_key(settings["profile_name"])
174
174
  add(
175
175
  "api key",
176
176
  bool(key),
177
177
  f"found ({key[:8]}...)" if key else "not found",
178
- None if key else "Run `memorysync init`, or set MEMORYSYNC_API_KEY.",
178
+ # Names the cross-CLI case explicitly. Someone who ran `init` moments ago
179
+ # and is told "not found" will otherwise reasonably conclude the CLI is
180
+ # broken, when on Windows the key is simply in the other CLI's store.
181
+ None
182
+ if key
183
+ else (
184
+ "Run `memorysync init`, or set MEMORYSYNC_API_KEY. If you stored a key "
185
+ "with the Node CLI on Windows, it is not shared: the two use different "
186
+ "ciphers, so run init here as well."
187
+ ),
179
188
  )
180
189
  add(
181
190
  "end user",
@@ -1,9 +1,17 @@
1
1
  """Profiles and settings, resolved in one documented order.
2
2
 
3
3
  Precedence, highest first: an explicit flag, then the environment, then the config
4
- file, then a built-in default. That order is what people expect and, more
5
- importantly, it is what the Node CLI does, so a script that sets
6
- ``MEMORYSYNC_USER`` behaves the same whichever CLI is installed.
4
+ file, then a built-in default. That order is what people expect and it is what the
5
+ Node CLI does.
6
+
7
+ Environment variable names are the Node CLI's, which are also the ones the
8
+ documentation lists. This module previously read ``MEMORYSYNC_USER`` and
9
+ ``MEMORYSYNC_PROJECT`` while Node read ``MEMORYSYNC_USER_ID`` and
10
+ ``MEMORYSYNC_PROJECT_ID``, and its own docstring claimed the two agreed. They did
11
+ not: every documented example that exported a default user worked under one CLI
12
+ and was silently ignored by the other, which then failed with "this command needs
13
+ an end user" and no indication why. The shorter names are still accepted as
14
+ aliases, because they were published and someone may have used them.
7
15
 
8
16
  The config file never holds a credential. It holds a profile name, a default end
9
17
  user, a project and a base URL - the things that are useful to read, commit to a
@@ -23,23 +31,23 @@ DEFAULT_TIMEOUT_MS = 60000
23
31
 
24
32
 
25
33
  def config_dir() -> Path:
26
- """Where settings live.
34
+ """Where settings live: ``~/.memorysync``, the same place the Node CLI uses.
27
35
 
28
36
  ``MEMORYSYNC_CONFIG_DIR`` is honoured first so tests and CI can point at a
29
37
  scratch directory instead of a developer's real profile.
38
+
39
+ This used to follow per-platform conventions - ``%APPDATA%\\memorysync`` on
40
+ Windows, ``$XDG_CONFIG_HOME/memorysync`` elsewhere - which is defensible on its
41
+ own but meant the two CLIs kept separate profile files. Running ``init`` under
42
+ one and a command under the other produced "No API key found" from a machine
43
+ that plainly had one, and the documentation's ``~/.memorysync`` was simply
44
+ wrong for this CLI. One location, shared, so a profile created by either is
45
+ seen by both.
30
46
  """
31
47
  override = os.environ.get("MEMORYSYNC_CONFIG_DIR")
32
48
  if override:
33
49
  return Path(override)
34
-
35
- if os.name == "nt":
36
- base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
37
- return Path(base) / "memorysync"
38
-
39
- xdg = os.environ.get("XDG_CONFIG_HOME")
40
- if xdg:
41
- return Path(xdg) / "memorysync"
42
- return Path.home() / ".config" / "memorysync"
50
+ return Path.home() / ".memorysync"
43
51
 
44
52
 
45
53
  def config_path() -> Path:
@@ -102,13 +110,20 @@ def resolve(flags: dict[str, Any]) -> dict[str, Any]:
102
110
  )
103
111
  stored = profile(profile_name)
104
112
 
105
- def pick(flag_key: str, env_key: str, stored_key: str, fallback: Any = None) -> Any:
113
+ def pick(
114
+ flag_key: str,
115
+ env_keys: str | tuple[str, ...],
116
+ stored_key: str,
117
+ fallback: Any = None,
118
+ ) -> Any:
106
119
  value = flags.get(flag_key)
107
120
  if value not in (None, False):
108
121
  return value
109
- env_value = os.environ.get(env_key)
110
- if env_value:
111
- return env_value
122
+ names = (env_keys,) if isinstance(env_keys, str) else env_keys
123
+ for name in names:
124
+ env_value = os.environ.get(name)
125
+ if env_value:
126
+ return env_value
112
127
  if stored.get(stored_key) is not None:
113
128
  return stored[stored_key]
114
129
  return fallback
@@ -124,8 +139,17 @@ def resolve(flags: dict[str, Any]) -> dict[str, Any]:
124
139
  "base_url": str(
125
140
  pick("base_url", "MEMORYSYNC_BASE_URL", "base_url", DEFAULT_BASE_URL)
126
141
  ).rstrip("/"),
127
- "user": pick("user", "MEMORYSYNC_USER", "user"),
128
- "project": pick("project", "MEMORYSYNC_PROJECT", "project"),
142
+ # ``_ID`` first: that is the documented name and what the Node CLI reads.
143
+ # The shorter form stays as an alias rather than being dropped, because it
144
+ # shipped and silently ignoring it would be a second surprise.
145
+ "user": pick("user", ("MEMORYSYNC_USER_ID", "MEMORYSYNC_USER"), "user"),
146
+ "project": pick(
147
+ "project", ("MEMORYSYNC_PROJECT_ID", "MEMORYSYNC_PROJECT"), "project"
148
+ ),
149
+ # Read here as well as in ``main`` so a format stored on the profile is
150
+ # honoured. Node resolves it from the profile too; omitting it meant
151
+ # `-o table` saved into a profile worked under one CLI only.
152
+ "output": pick("output", "MEMORYSYNC_OUTPUT", "output"),
129
153
  "timeout": timeout,
130
154
  }
131
155
 
@@ -1,8 +1,27 @@
1
1
  """Where the API key lives.
2
2
 
3
3
  Three tiers, in order: the environment, then the OS keychain, then an encrypted
4
- file readable only by the owner. Same order as the Node CLI, and reached through
5
- the same OS tools, so a key stored by one CLI is found by the other.
4
+ file readable only by the owner. Same order as the Node CLI and reached through the
5
+ same OS tools.
6
+
7
+ How much is shared with the Node CLI, precisely, because the previous version of
8
+ this docstring claimed "a key stored by one CLI is found by the other" and that
9
+ was not true on any platform:
10
+
11
+ * ``MEMORYSYNC_API_KEY`` shared. Same variable, wins everywhere.
12
+ * keychain (macOS/Linux) shared. Both use service ``memorysync-cli`` with the
13
+ profile name as the account. The account strings used
14
+ to differ, so neither found the other's entry.
15
+ * file (Windows) NOT shared, and cannot be. Node encrypts with
16
+ AES-256-GCM from ``node:crypto``; Python's standard
17
+ library has no AES and this package takes no
18
+ dependencies, so the two cannot read each other's
19
+ blob. Running ``init`` under each is required.
20
+
21
+ The file also moved from ``<config>/credentials`` to
22
+ ``<config>/credentials/<profile>.pyenc``. Now that both CLIs share
23
+ ``~/.memorysync``, a single file at that path would collide with the directory
24
+ Node creates there, and writing to it would fail with "is a directory".
6
25
 
7
26
  Stated plainly, because it affects what a customer should expect:
8
27
 
@@ -38,11 +57,24 @@ ENV_VAR = "MEMORYSYNC_API_KEY"
38
57
 
39
58
 
40
59
  def _account(profile: str) -> str:
41
- return f"{SERVICE}:{profile}"
60
+ """The keychain account, matching the Node CLI exactly.
61
+
62
+ Was ``f"{SERVICE}:{profile}"`` while Node used the bare profile name, under the
63
+ same service. Two entries, neither CLI finding the other's, on the two
64
+ platforms where a real keychain exists and sharing was actually possible.
65
+ """
66
+ return profile
42
67
 
43
68
 
44
- def _keyfile() -> Path:
45
- return config_dir() / "credentials"
69
+ def _keyfile(profile: str = "default") -> Path:
70
+ """One file per profile, inside the directory the Node CLI already uses.
71
+
72
+ A distinct extension rather than a distinct directory: the blobs are not
73
+ interchangeable (different ciphers) but they are the same kind of thing, and
74
+ keeping them together means ``credentials/`` is still the one place to look,
75
+ and to delete.
76
+ """
77
+ return config_dir() / "credentials" / f"{profile}.pyenc"
46
78
 
47
79
 
48
80
  def _obfuscation_key() -> bytes:
@@ -170,47 +202,37 @@ def _keychain_delete(profile: str) -> bool:
170
202
 
171
203
 
172
204
  def _file_read(profile: str) -> str | None:
173
- path = _keyfile()
205
+ path = _keyfile(profile)
174
206
  try:
175
- raw = path.read_text(encoding="utf-8")
176
- except (FileNotFoundError, OSError):
207
+ blob = path.read_text(encoding="utf-8").strip()
208
+ except (FileNotFoundError, OSError, IsADirectoryError):
209
+ return None
210
+ if not blob:
211
+ return None
212
+ try:
213
+ return _xor(base64.b64decode(blob), _obfuscation_key()).decode("utf-8")
214
+ except Exception: # noqa: BLE001 - a corrupt file is a miss, not a crash
177
215
  return None
178
-
179
- for line in raw.splitlines():
180
- name, _, blob = line.partition("=")
181
- if name != profile or not blob:
182
- continue
183
- try:
184
- return _xor(base64.b64decode(blob), _obfuscation_key()).decode("utf-8")
185
- except Exception: # noqa: BLE001 - a corrupt line is a miss, not a crash
186
- return None
187
- return None
188
216
 
189
217
 
190
218
  def _file_write(profile: str, key: str | None) -> None:
191
- path = _keyfile()
192
- path.parent.mkdir(parents=True, exist_ok=True)
193
-
194
- entries: dict[str, str] = {}
195
- try:
196
- for line in path.read_text(encoding="utf-8").splitlines():
197
- name, _, blob = line.partition("=")
198
- if name and blob:
199
- entries[name] = blob
200
- except (FileNotFoundError, OSError):
201
- pass
219
+ path = _keyfile(profile)
202
220
 
203
221
  if key is None:
204
- entries.pop(profile, None)
205
- else:
206
- entries[profile] = base64.b64encode(_xor(key.encode("utf-8"), _obfuscation_key())).decode("ascii")
222
+ try:
223
+ path.unlink()
224
+ except (FileNotFoundError, OSError):
225
+ pass
226
+ return
207
227
 
208
- # Written 0600 before any content lands, so there is no window where the file
228
+ path.parent.mkdir(parents=True, exist_ok=True)
229
+ blob = base64.b64encode(_xor(key.encode("utf-8"), _obfuscation_key())).decode("ascii")
230
+
231
+ # Opened 0600 before any content lands, so there is no window in which the file
209
232
  # exists with default permissions.
210
233
  descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
211
234
  with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
212
- for name, blob in entries.items():
213
- handle.write(f"{name}={blob}\n")
235
+ handle.write(blob + "\n")
214
236
 
215
237
 
216
238
  # ---------------------------------------------------------------------------
@@ -247,13 +269,16 @@ def delete_key(profile: str = "default") -> None:
247
269
  _file_write(profile, None)
248
270
 
249
271
 
250
- def describe_storage() -> str:
251
- """Human description of where a key would be stored, for `doctor`."""
272
+ def describe_storage(profile: str = "default") -> str:
273
+ """Human description of where a key would be stored, for `doctor`.
274
+
275
+ Says "this CLI only" on the file tier so a user who stored a key with the Node
276
+ CLI is told why it is not visible here, rather than being left to conclude the
277
+ key was never saved.
278
+ """
252
279
  tool = _keychain_tool()
253
280
  if tool == "security":
254
- return "macOS keychain (security)"
281
+ return "macOS keychain (security), shared with the Node CLI"
255
282
  if tool == "secret-tool":
256
- return "Linux keyring (secret-tool)"
257
- if platform.system() == "Windows":
258
- return f"encrypted file, owner-only ({_keyfile()})"
259
- return f"encrypted file, owner-only ({_keyfile()}) - no keychain tool found"
283
+ return "Linux keyring (secret-tool), shared with the Node CLI"
284
+ return f"encrypted file, owner-only, this CLI only ({_keyfile(profile)})"
@@ -20,6 +20,8 @@ an empty database.
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
+ import platform
24
+
23
25
  from .registry import exit_codes
24
26
 
25
27
  _CODES = exit_codes()
@@ -79,12 +81,32 @@ def usage_error(message: str, hint: str | None = None) -> CliError:
79
81
  return CliError(message, exit_code=Exit.USAGE, code="usage_error", hint=hint)
80
82
 
81
83
 
84
+ def _default_auth_hint() -> str:
85
+ """Why "no key" can be wrong-looking rather than wrong.
86
+
87
+ On Windows the two CLIs cannot share a stored key: Node encrypts with
88
+ AES-256-GCM and this package has no AES available without taking a dependency.
89
+ Someone who ran `memorysync init` seconds ago -- possibly reaching the *other*
90
+ CLI, since both install a binary of that name -- is otherwise told "No API key
91
+ found" by a machine that visibly has one, with nothing to act on.
92
+ """
93
+ base = "Run `memorysync init` to store a key, or set MEMORYSYNC_API_KEY."
94
+ if platform.system() == "Windows":
95
+ return (
96
+ base
97
+ + " On Windows the Node and Python CLIs keep separate key files, so a"
98
+ + " key stored by one is not visible to the other; run init here too,"
99
+ + " or set MEMORYSYNC_API_KEY, which both read."
100
+ )
101
+ return base
102
+
103
+
82
104
  def auth_error(message: str, hint: str | None = None) -> CliError:
83
105
  return CliError(
84
106
  message,
85
107
  exit_code=Exit.AUTH,
86
108
  code="auth_error",
87
- hint=hint or "Run `memorysync init` to store a key, or set MEMORYSYNC_API_KEY.",
109
+ hint=hint or _default_auth_hint(),
88
110
  )
89
111
 
90
112
 
@@ -33,10 +33,18 @@ _SEXAGESIMAL = re.compile(r"^\d+(:\d+)+$")
33
33
 
34
34
 
35
35
  def _colour_default() -> bool:
36
+ """Same decision the Node CLI makes, including ``TERM=dumb``.
37
+
38
+ ``TERM=dumb`` was honoured by Node and ignored here, so a terminal that
39
+ announces it cannot render ANSI got escape codes from one CLI and clean text
40
+ from the other.
41
+ """
36
42
  if os.environ.get("NO_COLOR"):
37
43
  return False
38
44
  if os.environ.get(_FORCE_COLOR):
39
45
  return True
46
+ if os.environ.get("TERM") == "dumb":
47
+ return False
40
48
  return bool(getattr(sys.stdout, "isatty", lambda: False)())
41
49
 
42
50
 
@@ -1 +0,0 @@
1
- __version__ = "1.0.2"
File without changes