plugsync-cli 0.6.0__py3-none-any.whl → 0.7.0__py3-none-any.whl

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.
plugsync_cli/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
1
  """PluSync CLI — git-like connector management."""
2
- __version__ = "0.6.0"
2
+ __version__ = "0.7.0"
@@ -63,7 +63,8 @@ console = Console()
63
63
  # Top-level config keys compared item-by-item (keyed by name) rather than whole.
64
64
  # `sources` reads like the other two here: a published version from before the
65
65
  # sources were versioned simply carries no key, so every local source shows up
66
- # as added, which is what it is against that version.
66
+ # as added, which is what it is against that version. The local side always
67
+ # declares the key: `read_config_directory` refuses a directory that does not.
67
68
  COLLECTIONS = {"entities": "entity", "flows": "flow", "sources": "source"}
68
69
 
69
70
  # Keys listed in one Detail cell before the rest is summarized. A row exists to
@@ -235,37 +236,25 @@ def _diff_configs(local: dict, remote: dict, root: SchemaNode) -> list[dict]:
235
236
 
236
237
 
237
238
  def _declaration_change(key: str, local: dict, remote: dict) -> list[dict]:
238
- """The row for a collection one side declares and the other does not.
239
-
240
- Only an optional collection can be in that position, and for `sources` the
241
- difference between "declared, and empty" and "not declared at all" is the
242
- whole point of the key: the first puts the definitions under the
243
- connector's config, the second leaves them to whatever manages them outside
244
- it. Item by item the two sides look identical -- no sources either way --
245
- so without this row the operator sees nothing at all, and the direction
246
- that DROPS the key is the one that reads as "no changes" while changing
247
- which document owns the definitions.
239
+ """The row for a collection the local files declare and the version does not.
240
+
241
+ Only an optional collection can be in that position, and only one way
242
+ round: the local directory always declares `sources` (a directory that does
243
+ not is refused before this runs), while a version published before the
244
+ sources were part of the config carries no key. Item by item "declared, and
245
+ empty" reads the same as that historical absence -- no sources either way
246
+ -- so without this row a directory with `sources: []` would diff clean
247
+ against a version that says nothing about sources.
248
248
  """
249
- if key not in OPTIONAL_COLLECTIONS:
249
+ if key not in OPTIONAL_COLLECTIONS or remote.get(key) is not None:
250
250
  return []
251
- in_local, in_remote = local.get(key) is not None, remote.get(key) is not None
252
- if in_local == in_remote:
253
- return []
254
- if in_local:
255
- return [
256
- _simple(
257
- f"{key}_key_added",
258
- key,
259
- f"declared in your files, absent from the published version: "
260
- f"publishing them puts the {key} under the connector's config",
261
- )
262
- ]
263
251
  return [
264
252
  _simple(
265
- f"{key}_key_removed",
253
+ f"{key}_key_added",
266
254
  key,
267
- f"declared in the published version, absent from your files: "
268
- f"pushing this directory takes the {key} out of the config",
255
+ f"declared in your files, absent from the published version, which "
256
+ f"predates {key} being part of the config: publishing makes the "
257
+ f"config the authority for the connector's {key}",
269
258
  )
270
259
  ]
271
260
 
@@ -137,7 +137,37 @@ def _publish(client, connector_id: str, message: str | None, republish: bool = F
137
137
  )
138
138
  return
139
139
  console.print(f"[green]Published v{result.get('version')}[/green]")
140
+ _reveal_created_secrets(result.get("created_sources") or [])
140
141
  warnings = result.get("warnings") or []
141
142
  if warnings:
142
143
  console.print("\nPublished with warnings:")
143
144
  render_findings(console, warnings)
145
+
146
+
147
+ def _reveal_created_secrets(created_sources: list[dict]) -> None:
148
+ """Print the plaintext secrets of the webhook sources this publish created.
149
+
150
+ A `webhook` source declared in the config is created by the publish itself
151
+ (ADR-0026), and the publish response is the ONLY place its secret ever
152
+ appears: nothing reads it back afterwards. Printing it here is what keeps
153
+ `plugsync push --publish` a complete way to stand a webhook source up;
154
+ without it the only way to a usable secret is to rotate the one that was
155
+ just generated. Sources with no secret to generate (every non-webhook
156
+ type) are skipped rather than printed as a bare name.
157
+ """
158
+ revealed = [s for s in created_sources if s.get("secret")]
159
+ if not revealed:
160
+ return
161
+ plural = "s" if len(revealed) > 1 else ""
162
+ console.print(
163
+ f"\n[bold]Webhook secret{plural} for the source{plural} this publish "
164
+ f"created - shown only once:[/bold]"
165
+ )
166
+ for source in revealed:
167
+ console.print(f" {source['name']}: [bold]{source['secret']}[/bold]")
168
+ console.print(
169
+ "[yellow]Copy each value now and configure it in the external system "
170
+ "that signs that source's deliveries. A secret is never shown again: "
171
+ "if one is lost, rotate the secret of that source from the dashboard "
172
+ "to issue a new one.[/yellow]"
173
+ )
plugsync_cli/context.py CHANGED
@@ -22,6 +22,7 @@ from plugsync_cli.serializer import (
22
22
  locate_in_directory,
23
23
  read_config_directory,
24
24
  read_local_state,
25
+ undeclared_collection_message,
25
26
  write_config_directory,
26
27
  write_local_state,
27
28
  )
@@ -131,17 +132,7 @@ def write_draft_from_directory(
131
132
  fires every time stops being read exactly when it matters.
132
133
  """
133
134
  config = context.config()
134
- # Read once, shared: the warning below and the concurrency guard both want
135
- # the remote draft, and neither is worth a round trip of its own.
136
- remote_draft = (
137
- client.get_draft(context.connector_id) if config.get("sources") is None else None
138
- )
139
- if remote_draft is not None:
140
- warn_if_the_push_takes_the_sources_out_of_the_config(remote_draft["config"])
141
-
142
- expected_version = (
143
- None if force else _expected_draft_version(client, context, remote_draft)
144
- )
135
+ expected_version = None if force else _expected_draft_version(client, context)
145
136
  try:
146
137
  draft = client.put_draft(
147
138
  context.connector_id, config, expected_version=expected_version
@@ -153,7 +144,7 @@ def write_draft_from_directory(
153
144
  "'plugsync push --force' to overwrite theirs."
154
145
  )
155
146
  except httpx.HTTPStatusError as exc:
156
- fail(_rejection_pointing_at_the_files(exc))
147
+ fail(_rejection_pointing_at_the_files(exc, context))
157
148
 
158
149
  warn_if_flow_order_is_not_preserved(draft["config"])
159
150
  write_config_directory(context.directory, draft["config"])
@@ -170,7 +161,24 @@ def write_draft_from_directory(
170
161
  return draft
171
162
 
172
163
 
173
- def _rejection_pointing_at_the_files(exc: httpx.HTTPStatusError) -> str:
164
+ # The API's error code for a draft write that omits `sources` on a connector
165
+ # that declares them. `read_config_directory` refuses such a directory before
166
+ # any request, so reaching it means the check was bypassed or the API moved.
167
+ SOURCES_KEY_REMOVAL_ERROR = "sources_key_removal_not_supported"
168
+
169
+
170
+ def _api_error_code(exc: httpx.HTTPStatusError) -> str | None:
171
+ """The machine-readable `detail.error` of a structured API refusal."""
172
+ try:
173
+ detail = exc.response.json().get("detail")
174
+ except (ValueError, AttributeError):
175
+ return None
176
+ return detail.get("error") if isinstance(detail, dict) else None
177
+
178
+
179
+ def _rejection_pointing_at_the_files(
180
+ exc: httpx.HTTPStatusError, context: ConnectorContext
181
+ ) -> str:
174
182
  """The API's refusal, with every config path it names resolved to a file.
175
183
 
176
184
  A rejected draft is described the way the config document reads
@@ -178,7 +186,12 @@ def _rejection_pointing_at_the_files(exc: httpx.HTTPStatusError) -> str:
178
186
  operator is looking at it: they have a directory of files open. Naming the
179
187
  file that holds the key, alongside the backend's own explanation, is what
180
188
  makes the failure actionable without decoding the path by hand.
189
+
190
+ A refusal to drop `sources` is about the directory as a whole, not a key in
191
+ it, so it gets the CLI's own explanation instead of the server's JSON.
181
192
  """
193
+ if _api_error_code(exc) == SOURCES_KEY_REMOVAL_ERROR:
194
+ return undeclared_collection_message(context.directory, "sources")
182
195
  message = describe_http_error(exc)
183
196
  located = locate_in_directory(message)
184
197
  if not located:
@@ -192,10 +205,10 @@ def _rejection_pointing_at_the_files(exc: httpx.HTTPStatusError) -> str:
192
205
  def describe_config(config: dict) -> str:
193
206
  """What a config holds, counted, for the line a pull or a push ends on.
194
207
 
195
- `sources` is reported only when the config declares the key. Its absence
196
- means the connector's sources are not versioned in the config at all, which
197
- is not the same as having none: printing "0 sources" would say the opposite
198
- of what an absent key means.
208
+ `sources` is reported only when the config declares the key. Only a
209
+ version published before the sources were part of the config lacks it, and
210
+ that is not the same as having none: printing "0 sources" would say
211
+ something the version never said.
199
212
  """
200
213
  counted = [
201
214
  _counted(config.get("entities"), "entity", "entities"),
@@ -211,43 +224,17 @@ def _counted(items, singular: str, plural: str) -> str:
211
224
  return f"{count} {singular if count == 1 else plural}"
212
225
 
213
226
 
214
- def warn_if_the_push_takes_the_sources_out_of_the_config(remote_config: dict) -> None:
215
- """Say it when this push drops a `sources` key the connector has.
216
-
217
- A directory with no `sources/` sends a config with no `sources` key, and
218
- that is not "this connector has no sources": it is the switch that hands
219
- the source definitions back to being managed outside the config. A
220
- directory pulled before the sources were part of it flips that switch on
221
- the next push, silently and with a zero exit code, which is why the one
222
- push that does it says so.
223
- """
224
- if remote_config.get("sources") is None:
225
- return
226
- console.print(
227
- "[yellow]The connector's draft declares 'sources' and this directory "
228
- "does not, so this push takes the source definitions out of the "
229
- "config: they go back to being managed outside it, and a later publish "
230
- "leaves them alone. Run 'plugsync pull' first if you meant to keep them "
231
- "in the directory.[/yellow]"
232
- )
233
-
234
-
235
- def _expected_draft_version(
236
- client: PlugSyncClient, context: ConnectorContext, draft: dict | None = None
237
- ) -> int:
227
+ def _expected_draft_version(client: PlugSyncClient, context: ConnectorContext) -> int:
238
228
  """The draft version a write guards itself with.
239
229
 
240
230
  Normally the one recorded at pull time, which is what makes a concurrent
241
231
  edit detectable. A directory pulled with `--revision` has none: there the
242
232
  current draft version is read now, which still rules out a write racing this
243
233
  command, and the weaker guarantee is stated out loud rather than assumed.
244
-
245
- `draft` is the remote draft when the caller has already read it, so the two
246
- reasons this function's caller has to fetch it cost one request, not two.
247
234
  """
248
235
  if context.draft_version is not None:
249
236
  return context.draft_version
250
- current = (draft or client.get_draft(context.connector_id))["draft_version"]
237
+ current = client.get_draft(context.connector_id)["draft_version"]
251
238
  console.print(
252
239
  f"[yellow]This directory was pulled from a published version, not the "
253
240
  f"draft: guarding against edits after draft v{current} only.[/yellow]"
@@ -62,23 +62,25 @@ LOCAL_STATE_FILE_NAME = ".plugsync.yaml"
62
62
  # in plugsync.yaml, mapped to the directory that holds them.
63
63
  COLLECTION_DIRS = {"entities": "entities", "flows": "flows", "sources": "sources"}
64
64
 
65
- # Collections whose ABSENCE means something other than "empty".
65
+ # Collections whose ABSENCE is not the same as "empty".
66
66
  #
67
- # `sources` is optional in the config (ADR-0026): a config with no `sources`
68
- # key leaves the connector on the path where the event source rows are
69
- # authoritative, while `sources: []` declares "this connector has no sources"
70
- # and makes the config the authority for them. The switch is per connector and
71
- # lives in the presence of the key, so the directory has to carry it: the
72
- # `sources/` directory exists exactly when the key does, empty directory
73
- # included. Materializing `sources: []` for a config that simply has no key --
74
- # the way `entities` and `flows` are materialized, where absent and empty are
75
- # the same thing -- would flip that switch on every pull.
67
+ # `sources` is a mandatory key of the config (ADR-0026): `sources: []` declares
68
+ # "this connector has no sources", and the API refuses a draft write that
69
+ # omits the key, because read as "no sources" the omission would delete every
70
+ # source the connector has at the next publish. The directory declares the key
71
+ # through the `sources/` directory, empty directory included.
72
+ #
73
+ # So a directory with neither `sources/` nor an inline `sources` key is
74
+ # refused on read (see `undeclared_collection_message`). Materializing
75
+ # `sources: []` for it -- the way `entities` and `flows` are materialized,
76
+ # where absent and empty are the same thing -- would turn "the directory is
77
+ # missing" into "delete all my sources". The CLI stops instead of guessing.
76
78
  OPTIONAL_COLLECTIONS = frozenset({"sources"})
77
79
 
78
80
  # Written into an optional collection's directory when it holds no item, because
79
81
  # git carries no empty directory and the directory is what declares the key:
80
- # without it, pull -> commit -> clone -> push would drop `sources: []` and put
81
- # the connector back on the legacy path with a zero exit code.
82
+ # without it, pull -> commit -> clone -> push would find no `sources/` and
83
+ # refuse a directory that declared `sources: []` when it was pulled.
82
84
  DIRECTORY_KEEP_FILE = ".gitkeep"
83
85
 
84
86
  # Characters kept as-is in a file name. Entity and flow names are free-form
@@ -94,6 +96,25 @@ class ConfigDirectoryError(Exception):
94
96
  """The local directory is not a readable connector config."""
95
97
 
96
98
 
99
+ def undeclared_collection_message(connector_dir: Path, key: str) -> str:
100
+ """What to do about a directory that does not declare `key` at all.
101
+
102
+ Shared by the local refusal in `read_config_directory` and by the
103
+ translation of the API's own refusal of the same thing, so the operator
104
+ reads one explanation whichever of the two catches it.
105
+ """
106
+ dir_name = COLLECTION_DIRS[key]
107
+ return (
108
+ f"{connector_dir} does not declare '{key}': there is no {dir_name}/ "
109
+ f"directory and no '{key}' key in {CONFIG_FILE_NAME}. Pushing it would "
110
+ f"ask the server to drop the connector's {key}, which it refuses. "
111
+ f"Run 'plugsync pull' to realign the directory with the draft; or "
112
+ f"create {dir_name}/ with an empty {DIRECTORY_KEEP_FILE} (and one file "
113
+ f"per {key[:-1]}) if you keep them as files; or write '{key}: []' in "
114
+ f"{CONFIG_FILE_NAME} if the connector really has no {key}."
115
+ )
116
+
117
+
97
118
  _TIMESTAMP_TAG = "tag:yaml.org,2002:timestamp"
98
119
 
99
120
 
@@ -317,7 +338,9 @@ def canonical_config(config: dict) -> dict:
317
338
  empty, so a config read from disk and one read from the API can be compared
318
339
  for equality (what `plugsync diff`, `validate` and `preview` do to tell
319
340
  local files from the remote draft). An optional collection the config does
320
- not declare stays undeclared: see `OPTIONAL_COLLECTIONS`.
341
+ not declare stays undeclared: a version published before the sources were
342
+ part of the config carries no `sources` key, and inventing `sources: []`
343
+ for it would describe that version as something it never said.
321
344
  """
322
345
  canonical = {k: v for k, v in config.items() if k not in COLLECTION_DIRS}
323
346
  for key in COLLECTION_DIRS:
@@ -399,12 +422,15 @@ def _write_collection(
399
422
 
400
423
 
401
424
  def _undeclare_collection(collection_dir: Path) -> None:
402
- """Erase an optional collection the config no longer declares.
403
-
404
- Its directory is what declares the key, so leaving an emptied one behind
405
- would make the next read say `sources: []` for a config that says nothing
406
- about sources at all. A directory holding files this format does not own
407
- (a README, a note) is emptied of its own and left standing.
425
+ """Erase an optional collection the config does not declare.
426
+
427
+ Only a version published before the sources were part of the config
428
+ (`pull --revision`) comes without the key. Its directory is what declares
429
+ the key, so leaving an emptied one behind would make the next read say
430
+ `sources: []` for a version that says nothing about sources at all; without
431
+ it, the next read refuses the directory and says how to declare them. A
432
+ directory holding files this format does not own (a README, a note) is
433
+ emptied of its own and left standing.
408
434
  """
409
435
  if not collection_dir.is_dir():
410
436
  return
@@ -424,6 +450,9 @@ def read_config_directory(connector_dir: Path, *, report: Report | None = None)
424
450
  (see `Normalization`). Commands pass a printer: the CLI says what it
425
451
  changed instead of changing it quietly. Omitting it discards the notes,
426
452
  which is only ever right for a caller that has already been told.
453
+
454
+ A directory that does not declare an optional collection is refused: see
455
+ `OPTIONAL_COLLECTIONS`.
427
456
  """
428
457
  report = report or _ignore
429
458
  config_path = connector_dir / CONFIG_FILE_NAME
@@ -438,7 +467,7 @@ def read_config_directory(connector_dir: Path, *, report: Report | None = None)
438
467
  _read_inline_collection(config, key, dir_name, collection_dir)
439
468
  continue
440
469
  if key in OPTIONAL_COLLECTIONS and not collection_dir.is_dir():
441
- continue
470
+ raise ConfigDirectoryError(undeclared_collection_message(connector_dir, key))
442
471
  items = [
443
472
  _load_yaml(path, report) for path in sorted(collection_dir.glob("*.yaml"))
444
473
  ] if collection_dir.is_dir() else []
@@ -479,10 +508,11 @@ def _read_inline_collection(
479
508
  if config[key] is None:
480
509
  # `sources:` with nothing after it. Not a declaration: read as an empty
481
510
  # list it would turn a config that says nothing about sources into one
482
- # declaring the connector has none, and it would disagree with
483
- # `canonical_config`, which treats null as absent on the same document.
484
- del config[key]
485
- return
511
+ # declaring the connector has none, which is the guess the CLI refuses
512
+ # to make for a missing `sources/` directory too.
513
+ raise ConfigDirectoryError(
514
+ undeclared_collection_message(collection_dir.parent, key)
515
+ )
486
516
  items = config[key]
487
517
  for item in items:
488
518
  _item_name(item, key, CONFIG_FILE_NAME)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plugsync-cli
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required
5
5
  Author-email: Exelab <hello@plugsync.com>
6
6
  License: Proprietary
@@ -1,26 +1,26 @@
1
- plugsync_cli/__init__.py,sha256=9pLbhLEWSNpmKOlVXv-RrmZ-SDMReErqgmTFQgJcKpY,75
1
+ plugsync_cli/__init__.py,sha256=PlGlt8CSRA3Ay-FfIqLXUuPNEOUwWaen2HnLvTWZukI,75
2
2
  plugsync_cli/bundler.py,sha256=nCskRqAILHe3LLMZSyS4klBbM879yBvVJODpZvEEm38,2219
3
3
  plugsync_cli/client.py,sha256=3AklXqJAtX4Jo6dk313-_f_oQFpawX2nUwUdaO-lFqM,15059
4
4
  plugsync_cli/compat.py,sha256=DCN-DO8FVQDjTz3sgMW3JAjIAWNhplan4zibDnwjK9w,4106
5
5
  plugsync_cli/config.py,sha256=z2DntLCY1RPU5gcqdDgkJ97fRd18clsXTvAlzLLcz10,1888
6
- plugsync_cli/context.py,sha256=x9KFpP3LbxQ6u9dHPnx9VIqiuBer50G5IBzOPEXQNOI,11272
6
+ plugsync_cli/context.py,sha256=5sk5_xkuL7LsE0fDitWVsKUArwx18Vyr47mgs-uMrUU,10624
7
7
  plugsync_cli/findings.py,sha256=ORWvqV7P8ss4Id846LZwWp5X6xEh7uZl6c86faDXfyQ,5526
8
8
  plugsync_cli/main.py,sha256=z_zM0GXm_tq_ZlNdFqlYuNQOk6cCdxsLagWmN95bAdw,3886
9
9
  plugsync_cli/schema_defaults.py,sha256=tSMmm9cjQlj2YvN7Q20s0rwoHjiOdH6CLttlfbLzmaE,8089
10
- plugsync_cli/serializer.py,sha256=AT4dibmjM6vNVq2cb6M-4uPTqd0q6KPRLDExS-uJXwo,23995
10
+ plugsync_cli/serializer.py,sha256=Nspm510tbgwI_udiPpNeXjO_NVWL-T1VEC9uvpXYqMM,25620
11
11
  plugsync_cli/commands/__init__.py,sha256=wgYTWnZKXF-4M9ITNDhZMTUJH6_FthcUt_5zGj9NS8I,20
12
12
  plugsync_cli/commands/auth.py,sha256=cAcN8ALfkEOcejB4s4zq19dxS6v6iAw691B0HPTQd-Q,5590
13
13
  plugsync_cli/commands/connector.py,sha256=FzoK7P5m-siCUpYeFkXbCzimMU_UePmbaP-wqcM_DU0,8751
14
- plugsync_cli/commands/diff.py,sha256=k7KOay6k4h27Ru2eiaHsCbQyyqn50e6LbFJLkYfW1PY,19937
14
+ plugsync_cli/commands/diff.py,sha256=zgTA5FeJLPnr-kiQaUezEyF5Wwq_2Gv4qnKnxvNkA7Q,19664
15
15
  plugsync_cli/commands/log.py,sha256=49GBgAvvs07d-OwMEgKKc7WalCKH2xmr5l7Ov6N13jM,3376
16
16
  plugsync_cli/commands/plugin.py,sha256=as1BM6lxpPcdrE12D--BCbSiriCLJbZED8utqJIGos8,20018
17
17
  plugsync_cli/commands/preview.py,sha256=7MyGDqyb_6XLiO6-CFyNJDBuD2BXqfKg8Y2U3ax2ptM,2960
18
18
  plugsync_cli/commands/pull.py,sha256=UXXaINuTHo3sroXAmfA7BZFQj7eG8OKrWCbvhu1Y05o,2479
19
- plugsync_cli/commands/push.py,sha256=49X7PeveY3oEWb3HrCKRzeqKKAdQGrbXcv9Yiz4y8Ag,5081
19
+ plugsync_cli/commands/push.py,sha256=xZNpRY4wAcTnkkw59Tg18lRoYilFCC-t8SR1RvC5CQA,6520
20
20
  plugsync_cli/commands/rollback.py,sha256=OtBk4U7yZubNVwhRmtyX0tSl20oWq8Pcy1Apjj_Ep-0,2471
21
21
  plugsync_cli/commands/validate.py,sha256=1ZjGSSkb21_2URmlyNaBBZXh-MI4L6cU_biKwJ6Itr4,1670
22
- plugsync_cli-0.6.0.dist-info/METADATA,sha256=tMq39rER6FCGqCsaV5W7wDOIFrCw6u-Eh62K8BtjJBo,4377
23
- plugsync_cli-0.6.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
24
- plugsync_cli-0.6.0.dist-info/entry_points.txt,sha256=qWyWFQxY9W7L0hEwoDFjQjgs7TxJMsETk2nIe8jCA9E,51
25
- plugsync_cli-0.6.0.dist-info/top_level.txt,sha256=tShdp15OlzUttfE_QlFMLIfwQfImdp4YVhmB5rXZCgA,13
26
- plugsync_cli-0.6.0.dist-info/RECORD,,
22
+ plugsync_cli-0.7.0.dist-info/METADATA,sha256=0ME4zUbvMWbTXNShCGxCbFasqd4utL2z-mnrOWKs5AA,4377
23
+ plugsync_cli-0.7.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
24
+ plugsync_cli-0.7.0.dist-info/entry_points.txt,sha256=qWyWFQxY9W7L0hEwoDFjQjgs7TxJMsETk2nIe8jCA9E,51
25
+ plugsync_cli-0.7.0.dist-info/top_level.txt,sha256=tShdp15OlzUttfE_QlFMLIfwQfImdp4YVhmB5rXZCgA,13
26
+ plugsync_cli-0.7.0.dist-info/RECORD,,