plugsync-cli 0.6.0__tar.gz → 0.7.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 (43) hide show
  1. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/PKG-INFO +1 -1
  2. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/__init__.py +1 -1
  3. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/diff.py +16 -27
  4. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/push.py +30 -0
  5. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/context.py +32 -45
  6. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/serializer.py +54 -24
  7. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/PKG-INFO +1 -1
  8. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/pyproject.toml +1 -1
  9. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_commands.py +195 -75
  10. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_e2e_draft_api.py +25 -8
  11. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_hand_edited_yaml.py +1 -0
  12. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_serializer.py +61 -26
  13. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/README.md +0 -0
  14. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/bundler.py +0 -0
  15. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/client.py +0 -0
  16. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/__init__.py +0 -0
  17. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/auth.py +0 -0
  18. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/connector.py +0 -0
  19. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/log.py +0 -0
  20. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/plugin.py +0 -0
  21. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/preview.py +0 -0
  22. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/pull.py +0 -0
  23. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/rollback.py +0 -0
  24. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/validate.py +0 -0
  25. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/compat.py +0 -0
  26. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/config.py +0 -0
  27. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/findings.py +0 -0
  28. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/main.py +0 -0
  29. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/schema_defaults.py +0 -0
  30. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/SOURCES.txt +0 -0
  31. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
  32. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
  33. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/requires.txt +0 -0
  34. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/top_level.txt +0 -0
  35. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/setup.cfg +0 -0
  36. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_api_contract.py +0 -0
  37. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_auth_commands.py +0 -0
  38. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_client.py +0 -0
  39. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_compat.py +0 -0
  40. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_config.py +0 -0
  41. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_connector_commands.py +0 -0
  42. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_plugin_commands.py +0 -0
  43. {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_schema_defaults.py +0 -0
@@ -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,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
+ )
@@ -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
@@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta"
11
11
 
12
12
  [project]
13
13
  name = "plugsync-cli"
14
- version = "0.6.0"
14
+ version = "0.7.0"
15
15
  description = "Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required"
16
16
  readme = "README.md"
17
17
  license = { text = "Proprietary" }
@@ -9,6 +9,7 @@ against a real server is `test_e2e_draft_api.py`'s.
9
9
  import os
10
10
 
11
11
  import httpx
12
+ import pytest
12
13
  import yaml
13
14
  from click.testing import CliRunner
14
15
  from unittest.mock import MagicMock, patch
@@ -28,6 +29,8 @@ CONFIG = {
28
29
  "flows": [
29
30
  {"name": "shop-orders", "source": "shop", "event_type": "order.created", "steps": []}
30
31
  ],
32
+ # Every draft the API answers declares the key (ADR-0026), `[]` included.
33
+ "sources": [],
31
34
  }
32
35
 
33
36
 
@@ -494,6 +497,55 @@ class TestPush:
494
497
  assert "envelope_like_path" in result.output
495
498
  assert "portal_unreachable" in result.output
496
499
 
500
+ def test_push_publish_reveals_the_secrets_of_the_sources_it_created(self, tmp_path):
501
+ """#1570 / UI-42: a `webhook` source declared in the draft is created
502
+ BY the publish, and `created_sources` is the one and only place its
503
+ plaintext secret appears. Swallowing it would leave the operator with a
504
+ live source and no usable secret."""
505
+ directory = _local_dir(tmp_path, "shop")
506
+ client = _mock_client()
507
+ client.publish.return_value = {
508
+ "version": 5,
509
+ "warnings": [],
510
+ "created_sources": [
511
+ {"name": "shop_hook", "secret": "whsec-0123456789abcdef"},
512
+ {"name": "erp_hook", "secret": "whsec-second-value"},
513
+ # No secret to generate: must not print a bare name with an
514
+ # empty value next to two real secrets.
515
+ {"name": "nightly_poller", "secret": None},
516
+ ],
517
+ }
518
+
519
+ runner = CliRunner()
520
+ with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
521
+ result = _run_ok(runner, ["push", str(directory), "--publish"])
522
+
523
+ out = _flat(result.output)
524
+ assert "whsec-0123456789abcdef" in out
525
+ assert "whsec-second-value" in out
526
+ assert "shop_hook" in out and "erp_hook" in out
527
+ assert "nightly_poller" not in out
528
+ assert "only once" in out.lower()
529
+ # Unrecoverable by design, so the warning has to name the way back.
530
+ assert "rotate" in out.lower()
531
+
532
+ def test_push_publish_says_nothing_when_no_source_carried_a_secret(self, tmp_path):
533
+ directory = _local_dir(tmp_path, "shop")
534
+ client = _mock_client()
535
+ client.publish.return_value = {
536
+ "version": 5,
537
+ "warnings": [],
538
+ "created_sources": [{"name": "nightly_poller", "secret": None}],
539
+ }
540
+
541
+ runner = CliRunner()
542
+ with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
543
+ result = _run_ok(runner, ["push", str(directory), "--publish"])
544
+
545
+ out = _flat(result.output).lower()
546
+ assert "published v5" in out
547
+ assert "secret" not in out
548
+
497
549
  def test_push_publish_refuses_on_a_preflight_error(self, tmp_path):
498
550
  directory = _local_dir(tmp_path, "shop")
499
551
  client = _mock_client()
@@ -905,6 +957,7 @@ class TestDiffNamesWhatChanged:
905
957
  "steps": [{"id": "upsert", "action": "upsert", "target": "hubspot"}],
906
958
  }
907
959
  ],
960
+ "sources": [],
908
961
  }
909
962
 
910
963
  @staticmethod
@@ -929,6 +982,7 @@ class TestDiffNamesWhatChanged:
929
982
  "steps": [step],
930
983
  }
931
984
  ],
985
+ "sources": [],
932
986
  }
933
987
 
934
988
  def test_only_a_materialized_default_is_not_a_change_of_yours(self, tmp_path):
@@ -1014,6 +1068,7 @@ class TestDiffNamesWhatChanged:
1014
1068
  }
1015
1069
  ],
1016
1070
  "flows": [],
1071
+ "sources": [],
1017
1072
  }
1018
1073
 
1019
1074
  EMAIL = {"source": "$.payload.email", "target": "email"}
@@ -1221,6 +1276,7 @@ class TestDiffPluginEmbeddedFields:
1221
1276
  "steps": [step],
1222
1277
  }
1223
1278
  ],
1279
+ "sources": [],
1224
1280
  }
1225
1281
 
1226
1282
  def _embedded_step(self, **overrides) -> dict:
@@ -1491,15 +1547,17 @@ SOURCES = [
1491
1547
  ]
1492
1548
 
1493
1549
  CONFIG_WITH_SOURCES = {**CONFIG, "sources": SOURCES}
1550
+ # A version published before the sources were part of the config: no key.
1551
+ CONFIG_BEFORE_SOURCES = {k: v for k, v in CONFIG.items() if k != "sources"}
1494
1552
 
1495
1553
 
1496
1554
  class TestSources:
1497
1555
  """The connector directory carries the sources too, losslessly.
1498
1556
 
1499
- The key is optional in the config and its absence is not an empty list: it
1500
- is the per-connector switch between "the event source rows are
1501
- authoritative" and "the config is". A pull that invented `sources: []`, or
1502
- a push that dropped the key, would flip that switch behind the operator.
1557
+ Every draft declares the key (ADR-0026), `[]` included, and `[]` means
1558
+ "this connector has no sources": the publish deletes whatever rows it had.
1559
+ So a pull must not invent the key for a version that lacks it, and an
1560
+ empty declaration must survive the round trip as an empty directory.
1503
1561
  """
1504
1562
 
1505
1563
  def test_pull_writes_one_file_per_source_and_counts_them(self, tmp_path):
@@ -1520,13 +1578,18 @@ class TestSources:
1520
1578
  assert yaml.safe_load((out / "sources" / "hubspot.yaml").read_text()) == SOURCES[0]
1521
1579
  assert "3 sources" in _flat(result.output)
1522
1580
 
1523
- def test_pull_says_nothing_about_sources_when_the_config_has_none(self, tmp_path):
1581
+ def test_pull_says_nothing_about_sources_when_the_version_has_no_key(self, tmp_path):
1582
+ """A version published before sources were versioned: "0 sources"
1583
+ would say something that version never said."""
1524
1584
  client = _mock_client()
1525
1585
  client.find_connector.return_value = {"id": CONNECTOR_ID, "name": "shop"}
1586
+ client.get_version.return_value = _version_response(1, CONFIG_BEFORE_SOURCES)
1526
1587
 
1527
1588
  runner = CliRunner()
1528
1589
  with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
1529
- result = _run_ok(runner, ["pull", "shop", "-o", str(tmp_path / "shop")])
1590
+ result = _run_ok(
1591
+ runner, ["pull", "shop", "--revision", "1", "-o", str(tmp_path / "shop")]
1592
+ )
1530
1593
 
1531
1594
  assert not (tmp_path / "shop" / "sources").exists()
1532
1595
  assert "source" not in _flat(result.output).lower()
@@ -1557,16 +1620,6 @@ class TestSources:
1557
1620
 
1558
1621
  assert client.put_draft.call_args[0][1]["sources"] == []
1559
1622
 
1560
- def test_push_does_not_invent_the_key_for_a_directory_without_it(self, tmp_path):
1561
- directory = _local_dir(tmp_path, "shop")
1562
- client = _mock_client()
1563
-
1564
- runner = CliRunner()
1565
- with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
1566
- _run_ok(runner, ["push", str(directory)])
1567
-
1568
- assert "sources" not in client.put_draft.call_args[0][1]
1569
-
1570
1623
  def test_a_rejected_source_is_reported_by_file_and_key(self, tmp_path):
1571
1624
  """A 422 names a path in the config; the operator sees a directory."""
1572
1625
  directory = _local_dir(tmp_path, "shop", config=CONFIG_WITH_SOURCES)
@@ -1683,100 +1736,162 @@ class TestSources:
1683
1736
  assert "sources[hubspot].config.triggers[0].pattern" in flat
1684
1737
 
1685
1738
 
1686
- class TestSourcesKeyTransition:
1687
- """The `sources` key appearing or disappearing is a change in itself.
1739
+ def _undeclared_dir(tmp_path, name="shop"):
1740
+ """A directory that declares no sources: no `sources/`, no inline key.
1688
1741
 
1689
- Compared item by item, "declared empty" and "not declared at all" look
1690
- identical: no sources on either side. They are not the same thing -- one
1691
- puts the definitions under the connector's config, the other leaves them
1692
- to whatever manages them outside it -- so the transition needs saying out
1693
- loud, in `diff` and on the push that performs it.
1742
+ What a CLI 0.5.x pull without inline sources leaves behind, or a clone of
1743
+ a repository where the `.gitkeep` holding up an empty `sources/` was lost.
1694
1744
  """
1745
+ return _local_dir(tmp_path, name, config=CONFIG_BEFORE_SOURCES)
1695
1746
 
1696
- def test_diff_says_the_published_version_declares_sources_and_the_files_do_not(
1697
- self, tmp_path
1698
- ):
1699
- directory = _local_dir(tmp_path, "shop")
1747
+
1748
+ def _assert_says_how_to_declare_the_sources(output: str, directory) -> None:
1749
+ flat = _flat(output)
1750
+ assert str(directory) in flat
1751
+ assert "does not declare 'sources'" in flat
1752
+ assert "plugsync pull" in flat
1753
+ assert ".gitkeep" in flat
1754
+ assert "sources: []" in flat
1755
+
1756
+
1757
+ class TestUndeclaredSources:
1758
+ """A directory that does not declare `sources` stops in local (#1630).
1759
+
1760
+ The API refuses a draft write that omits the key (422
1761
+ `sources_key_removal_not_supported`), and `sources: []` in its place would
1762
+ delete every source at the next publish. So the CLI neither sends the
1763
+ directory as it is nor fills the gap: it stops before any request and says
1764
+ how to declare the sources.
1765
+ """
1766
+
1767
+ @pytest.mark.parametrize(
1768
+ "args",
1769
+ [["push"], ["push", "--force"], ["validate"], ["validate", "--push"], ["preview"], ["diff"]],
1770
+ )
1771
+ def test_the_command_stops_before_touching_the_api(self, tmp_path, args):
1772
+ directory = _undeclared_dir(tmp_path)
1700
1773
  client = _mock_client()
1701
- client.list_revisions.return_value = [{"version": 2, "is_active": True}]
1702
- client.get_version.return_value = _version_response(2, {**CONFIG, "sources": []})
1703
1774
 
1704
1775
  runner = CliRunner()
1705
1776
  with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
1706
- result = _run_ok(runner, ["diff", str(directory)])
1777
+ result = runner.invoke(cli, [*args, str(directory)])
1707
1778
 
1708
- flat = _flat(result.output)
1709
- assert "sources_key_removed" in flat
1710
- assert "1 change detected" in flat
1779
+ assert result.exit_code == 1, result.output
1780
+ _assert_says_how_to_declare_the_sources(result.output, directory)
1781
+ assert client.method_calls == []
1782
+
1783
+ def test_a_null_inline_key_is_refused_the_same_way(self, tmp_path):
1784
+ directory = _undeclared_dir(tmp_path)
1785
+ config_file = directory / "plugsync.yaml"
1786
+ config_file.write_text(config_file.read_text() + "sources:\n")
1787
+ client = _mock_client()
1788
+
1789
+ runner = CliRunner()
1790
+ with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
1791
+ result = runner.invoke(cli, ["push", str(directory)])
1792
+
1793
+ assert result.exit_code == 1
1794
+ _assert_says_how_to_declare_the_sources(result.output, directory)
1795
+ client.put_draft.assert_not_called()
1711
1796
 
1712
- def test_diff_says_the_files_declare_sources_and_the_published_version_does_not(
1797
+ def test_a_sources_directory_holding_only_the_placeholder_sends_an_empty_list(
1713
1798
  self, tmp_path
1714
1799
  ):
1715
- directory = _local_dir(tmp_path, "shop", config={**CONFIG, "sources": []})
1800
+ directory = _undeclared_dir(tmp_path)
1801
+ (directory / "sources").mkdir()
1802
+ (directory / "sources" / ".gitkeep").touch()
1716
1803
  client = _mock_client()
1717
- client.list_revisions.return_value = [{"version": 2, "is_active": True}]
1718
- client.get_version.return_value = _version_response(2, CONFIG)
1719
1804
 
1720
1805
  runner = CliRunner()
1721
1806
  with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
1722
- result = _run_ok(runner, ["diff", str(directory)])
1807
+ _run_ok(runner, ["push", str(directory)])
1723
1808
 
1724
- assert "sources_key_added" in _flat(result.output)
1809
+ assert client.put_draft.call_args[0][1]["sources"] == []
1725
1810
 
1726
- def test_diff_is_quiet_when_both_sides_declare_the_key(self, tmp_path):
1727
- directory = _local_dir(tmp_path, "shop", config={**CONFIG, "sources": []})
1811
+ def test_inline_sources_written_by_a_0_5_cli_are_pushed_and_split_into_files(
1812
+ self, tmp_path
1813
+ ):
1814
+ directory = _undeclared_dir(tmp_path)
1815
+ config_file = directory / "plugsync.yaml"
1816
+ inline = {**yaml.safe_load(config_file.read_text()), "sources": SOURCES}
1817
+ config_file.write_text(yaml.dump(inline))
1728
1818
  client = _mock_client()
1729
- client.list_revisions.return_value = [{"version": 2, "is_active": True}]
1730
- client.get_version.return_value = _version_response(2, {**CONFIG, "sources": []})
1819
+ client.put_draft.return_value = _draft_response(CONFIG_WITH_SOURCES, draft_version=4)
1731
1820
 
1732
1821
  runner = CliRunner()
1733
1822
  with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
1734
- result = _run_ok(runner, ["diff", str(directory)])
1823
+ _run_ok(runner, ["push", str(directory)])
1735
1824
 
1736
- assert "sources_key" not in _flat(result.output)
1825
+ sent = client.put_draft.call_args[0][1]
1826
+ assert sent["sources"] == sorted(SOURCES, key=lambda s: s["name"])
1827
+ assert (directory / "sources" / "hubspot.yaml").exists()
1828
+ assert "sources" not in yaml.safe_load(config_file.read_text())
1737
1829
 
1738
- def test_push_warns_when_it_takes_the_key_off_the_connector(self, tmp_path):
1739
- """A directory predating `sources/` pushes the key away, silently."""
1830
+ def test_the_servers_refusal_is_translated_into_the_directorys_terms(self, tmp_path):
1831
+ """If the request goes out anyway, the 422 is not printed as JSON."""
1740
1832
  directory = _local_dir(tmp_path, "shop")
1833
+ client = _mock_client(
1834
+ put_draft=MagicMock(
1835
+ side_effect=_http_error(
1836
+ 422,
1837
+ {
1838
+ "detail": {
1839
+ "error": "sources_key_removal_not_supported",
1840
+ "message": "this draft declares 'sources', and the "
1841
+ "declaration cannot be withdrawn",
1842
+ }
1843
+ },
1844
+ )
1845
+ )
1846
+ )
1847
+
1848
+ runner = CliRunner()
1849
+ with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
1850
+ result = runner.invoke(cli, ["push", str(directory)])
1851
+
1852
+ assert result.exit_code == 1
1853
+ _assert_says_how_to_declare_the_sources(result.output, directory)
1854
+ assert "sources_key_removal_not_supported" not in _flat(result.output)
1855
+
1856
+ def test_diff_never_reports_the_key_as_removed(self, tmp_path):
1857
+ """The direction that drops the key is an error, not a change."""
1858
+ directory = _undeclared_dir(tmp_path)
1741
1859
  client = _mock_client()
1742
- client.get_draft.return_value = _draft_response(CONFIG_WITH_SOURCES)
1860
+ client.list_revisions.return_value = [{"version": 2, "is_active": True}]
1861
+ client.get_version.return_value = _version_response(2, CONFIG)
1743
1862
 
1744
1863
  runner = CliRunner()
1745
1864
  with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
1746
- result = _run_ok(runner, ["push", str(directory)])
1865
+ result = runner.invoke(cli, ["diff", str(directory)])
1747
1866
 
1748
- flat = _flat(result.output).lower()
1749
- assert "sources" in flat
1750
- assert "plugsync pull" in flat
1867
+ assert result.exit_code == 1
1868
+ assert "sources_key_removed" not in _flat(result.output)
1751
1869
 
1752
- def test_push_is_quiet_when_the_draft_declares_no_sources_either(self, tmp_path):
1870
+ def test_diff_says_the_published_version_predates_the_sources(self, tmp_path):
1871
+ """A version published before sources were versioned has no key; the
1872
+ local files always declare it, so this is the one transition left."""
1753
1873
  directory = _local_dir(tmp_path, "shop")
1754
1874
  client = _mock_client()
1875
+ client.list_revisions.return_value = [{"version": 2, "is_active": True}]
1876
+ client.get_version.return_value = _version_response(2, CONFIG_BEFORE_SOURCES)
1755
1877
 
1756
1878
  runner = CliRunner()
1757
1879
  with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
1758
- result = _run_ok(runner, ["push", str(directory)])
1759
-
1760
- assert "removes" not in _flat(result.output).lower()
1880
+ result = _run_ok(runner, ["diff", str(directory)])
1761
1881
 
1762
- def test_push_reads_the_draft_once_when_it_has_to_read_it_at_all(self, tmp_path):
1763
- """The check costs one request, and only when it can fire.
1882
+ assert "sources_key_added" in _flat(result.output)
1764
1883
 
1765
- A directory pulled from a published version already reads the draft for
1766
- its concurrency guard; the two must not become two round trips.
1767
- """
1768
- directory = _local_dir(
1769
- tmp_path,
1770
- "shop",
1771
- state={"connector": "shop", "connector_id": CONNECTOR_ID, "source": "version 2"},
1772
- )
1884
+ def test_diff_is_quiet_when_both_sides_declare_the_key(self, tmp_path):
1885
+ directory = _local_dir(tmp_path, "shop")
1773
1886
  client = _mock_client()
1887
+ client.list_revisions.return_value = [{"version": 2, "is_active": True}]
1888
+ client.get_version.return_value = _version_response(2, CONFIG)
1774
1889
 
1775
1890
  runner = CliRunner()
1776
1891
  with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
1777
- _run_ok(runner, ["push", str(directory)])
1892
+ result = _run_ok(runner, ["diff", str(directory)])
1778
1893
 
1779
- client.get_draft.assert_called_once_with(CONNECTOR_ID)
1894
+ assert "sources_key" not in _flat(result.output)
1780
1895
 
1781
1896
  def test_push_reads_no_draft_when_the_directory_declares_sources(self, tmp_path):
1782
1897
  directory = _local_dir(tmp_path, "shop", config=CONFIG_WITH_SOURCES)
@@ -1788,21 +1903,21 @@ class TestSourcesKeyTransition:
1788
1903
 
1789
1904
  client.get_draft.assert_not_called()
1790
1905
 
1791
- def test_pull_revision_of_a_version_without_the_key_removes_the_directory(
1906
+ def test_pull_revision_of_a_version_without_the_key_leaves_a_directory_push_refuses(
1792
1907
  self, tmp_path
1793
1908
  ):
1794
1909
  """Documented asymmetry: `pull --revision` + `push` is not `rollback`.
1795
1910
 
1796
1911
  A version published before the sources were part of the config carries
1797
- no key, and a pull SYNCS the directory to what it pulled. So the local
1798
- `sources/` goes, and pushing that directory would take the key off the
1799
- connector -- which `rollback`, restoring the whole blob into the draft,
1800
- does not do.
1912
+ no key, and a pull SYNCS the directory to what it pulled, so the local
1913
+ `sources/` goes. Pushing that directory would ask the server to drop
1914
+ the key, so it is refused in local; `rollback`, restoring the whole
1915
+ blob into the draft, is the way back to that version.
1801
1916
  """
1802
1917
  client = _mock_client()
1803
1918
  client.find_connector.return_value = {"id": CONNECTOR_ID, "name": "shop"}
1804
1919
  client.get_draft.return_value = _draft_response(CONFIG_WITH_SOURCES)
1805
- client.get_version.return_value = _version_response(1, CONFIG)
1920
+ client.get_version.return_value = _version_response(1, CONFIG_BEFORE_SOURCES)
1806
1921
  out = tmp_path / "shop"
1807
1922
 
1808
1923
  runner = CliRunner()
@@ -1811,5 +1926,10 @@ class TestSourcesKeyTransition:
1811
1926
  assert (out / "sources").is_dir()
1812
1927
 
1813
1928
  _run_ok(runner, ["pull", "shop", "--revision", "1", "-o", str(out)])
1929
+ assert not (out / "sources").exists()
1814
1930
 
1815
- assert not (out / "sources").exists()
1931
+ result = runner.invoke(cli, ["push", str(out)])
1932
+
1933
+ assert result.exit_code == 1
1934
+ _assert_says_how_to_declare_the_sources(result.output, out)
1935
+ client.put_draft.assert_not_called()
@@ -415,7 +415,15 @@ def test_second_pull_carries_a_deletion_made_elsewhere(live_backend, connector,
415
415
  assert [e["name"] for e in live_backend.get(draft_path)["config"]["entities"]] == ["contacts"]
416
416
 
417
417
  # Somebody else drops the entity, through the same API the dashboard uses.
418
- live_backend.put(draft_path, {"version": "3.1", "entities": [], "flows": []})
418
+ # The `sources` declaration is carried over from the stored config rather
419
+ # than retyped: a body that omits the key on a draft that declares it is
420
+ # refused with `sources_key_removal_not_supported` (#1541), and this test
421
+ # is about the entity deletion, not about withdrawing the declaration.
422
+ stored = live_backend.get(draft_path)["config"]
423
+ edited = {"version": "3.1", "entities": [], "flows": []}
424
+ if "sources" in stored:
425
+ edited["sources"] = stored["sources"]
426
+ live_backend.put(draft_path, edited)
419
427
  assert live_backend.get(draft_path)["config"]["entities"] == []
420
428
 
421
429
  _run(runner, ["pull", connector["name"], "-o", str(workdir)])
@@ -629,19 +637,28 @@ def test_sources_survive_a_pull_push_round_trip(live_backend, connector, cli_env
629
637
 
630
638
  The round trip is the whole promise of `sources/`: an operator who pulls a
631
639
  connector and pushes it back untouched must end up with the draft they
632
- started from, sources included. The `sources` key is also the per-connector
633
- switch between "the event source rows are authoritative" and "the config
634
- is" (ADR-0026), so a push that dropped it, or a pull that invented it,
635
- would flip that switch behind their back.
640
+ started from, sources included. The `sources` key is mandatory in a draft
641
+ (ADR-0026), and `sources: []` deletes the connector's sources at publish,
642
+ so the empty declaration must travel as itself, never as a missing key.
643
+
644
+ Since #1541 a draft the backend materializes is born declaring the sources
645
+ the connector already has, which for a connector with no rows is the empty
646
+ declaration `sources: []`. The directory has to carry that distinction: an
647
+ EMPTY `sources/` directory means "declares zero sources", and the push must
648
+ send `sources: []` back rather than omit the key - omitting it is refused
649
+ with `sources_key_removal_not_supported`.
636
650
  """
637
651
  runner, workdir = cli_env, tmp_path / connector["name"]
638
652
  draft_path = f"/api/connectors/{connector['id']}/draft"
639
653
 
640
- # A draft that has never declared sources stays that way through a pull.
654
+ # The empty declaration survives a pull as an empty directory, and a push
655
+ # sends it back as an empty list, not as an absent key.
641
656
  _run(runner, ["pull", connector["name"], "-o", str(workdir)])
642
- assert not (workdir / "sources").exists()
657
+ assert live_backend.get(draft_path)["config"]["sources"] == []
658
+ assert (workdir / "sources").is_dir()
659
+ assert list((workdir / "sources").glob("*.yaml")) == []
643
660
  _run(runner, ["push", str(workdir)])
644
- assert "sources" not in live_backend.get(draft_path)["config"]
661
+ assert live_backend.get(draft_path)["config"]["sources"] == []
645
662
 
646
663
  # Declare them as files, exactly as a config-as-code repo would.
647
664
  _write_sources(workdir, E2E_SOURCES)
@@ -57,6 +57,7 @@ def _read(tmp_path, plugsync_yaml: str, notes: list | None = None) -> dict:
57
57
  directory = tmp_path / "conn"
58
58
  directory.mkdir()
59
59
  (directory / "plugsync.yaml").write_text(plugsync_yaml)
60
+ (directory / "sources").mkdir()
60
61
  return read_config_directory(
61
62
  directory, report=None if notes is None else notes.append
62
63
  )
@@ -46,6 +46,8 @@ FULL_CONFIG = {
46
46
  "unknown_flow_key": ["kept"],
47
47
  }
48
48
  ],
49
+ # Mandatory in every config the API answers (ADR-0026).
50
+ "sources": [],
49
51
  }
50
52
 
51
53
 
@@ -82,8 +84,8 @@ def test_layout_is_one_readable_file_per_named_item(tmp_path):
82
84
  assert "flows" not in top_level
83
85
 
84
86
 
85
- def test_empty_config_round_trips_with_both_collections_present(tmp_path):
86
- write_config_directory(tmp_path / "conn", {"version": "3.1"})
87
+ def test_empty_config_round_trips_with_every_collection_present(tmp_path):
88
+ write_config_directory(tmp_path / "conn", {"version": "3.1", "sources": []})
87
89
 
88
90
  # An empty collection is not written as an empty directory (git would drop
89
91
  # it anyway), and reads back as the empty list the backend defaults to.
@@ -92,6 +94,7 @@ def test_empty_config_round_trips_with_both_collections_present(tmp_path):
92
94
  "version": "3.1",
93
95
  "entities": [],
94
96
  "flows": [],
97
+ "sources": [],
95
98
  }
96
99
 
97
100
 
@@ -141,6 +144,7 @@ def test_names_that_are_not_valid_filenames_still_round_trip(tmp_path):
141
144
  "version": "3.1",
142
145
  "entities": [{"name": "shop/orders v2", "schemas": {}}],
143
146
  "flows": [],
147
+ "sources": [],
144
148
  }
145
149
  write_config_directory(tmp_path / "conn", config)
146
150
 
@@ -195,6 +199,7 @@ def test_non_ascii_is_written_as_text_not_escapes(tmp_path):
195
199
  "version": "3.1",
196
200
  "settings": {"label": label},
197
201
  "entities": [{"name": "citta", "schemas": {}, "note": "perché sì"}],
202
+ "sources": [],
198
203
  }
199
204
  write_config_directory(tmp_path / "conn", config)
200
205
 
@@ -241,11 +246,12 @@ def test_missing_local_state_points_at_pull(tmp_path):
241
246
 
242
247
 
243
248
  # `sources` is the one collection whose ABSENCE is not an empty list (ADR-0026):
244
- # a config without the key leaves the connector on the path where the event
245
- # source rows are authoritative, `sources: []` declares "this connector has no
246
- # sources" and makes the config authoritative. The directory has to carry that
247
- # distinction, so it is the `sources/` directory itself that says the key is
248
- # declared.
249
+ # `sources: []` declares "this connector has no sources", while a directory
250
+ # that declares nothing is refused on read instead of guessed at -- the API
251
+ # refuses a draft that omits the key, and `[]` in its place would delete every
252
+ # source at the next publish. It is the `sources/` directory itself that says
253
+ # the key is declared.
254
+ WITHOUT_SOURCES = {k: v for k, v in FULL_CONFIG.items() if k != "sources"}
249
255
  THREE_SOURCES = [
250
256
  {"name": "hubspot", "type": "hubspot", "config": {"triggers": [{"property": "email"}]}},
251
257
  {"name": "shop", "type": "event_api", "config": {}},
@@ -273,13 +279,34 @@ class TestSources:
273
279
  top_level = yaml.safe_load((tmp_path / "conn" / "plugsync.yaml").read_text())
274
280
  assert "sources" not in top_level
275
281
 
276
- def test_a_config_without_the_key_does_not_grow_one(self, tmp_path):
277
- """Absence is the legacy switch: inventing `sources: []` would flip it."""
278
- write_config_directory(tmp_path / "conn", FULL_CONFIG)
282
+ def test_a_version_without_the_key_does_not_grow_one(self, tmp_path):
283
+ """A version published before sources were versioned has no key.
284
+
285
+ Written as it is, and compared as it is: inventing `sources: []` for it
286
+ would describe it as declaring something it never said.
287
+ """
288
+ write_config_directory(tmp_path / "conn", WITHOUT_SOURCES)
279
289
 
280
290
  assert not (tmp_path / "conn" / "sources").exists()
281
- assert "sources" not in read_config_directory(tmp_path / "conn")
282
- assert "sources" not in canonical_config(FULL_CONFIG)
291
+ assert "sources" not in canonical_config(WITHOUT_SOURCES)
292
+
293
+ def test_a_directory_that_does_not_declare_the_sources_is_refused(self, tmp_path):
294
+ """No `sources/` and no inline key: stop, and say how to declare them.
295
+
296
+ Reading it as `sources: []` would turn "the directory is missing" into
297
+ "delete every source"; leaving the key out would earn a 422 from the
298
+ server that says nothing about the directory.
299
+ """
300
+ write_config_directory(tmp_path / "conn", WITHOUT_SOURCES)
301
+
302
+ with pytest.raises(ConfigDirectoryError) as refused:
303
+ read_config_directory(tmp_path / "conn")
304
+
305
+ message = str(refused.value)
306
+ assert str(tmp_path / "conn") in message
307
+ assert "plugsync pull" in message
308
+ assert "sources/" in message and ".gitkeep" in message
309
+ assert "sources: []" in message
283
310
 
284
311
  def test_an_empty_list_is_kept_apart_from_the_key_being_absent(self, tmp_path):
285
312
  write_config_directory(tmp_path / "conn", {**FULL_CONFIG, "sources": []})
@@ -287,11 +314,20 @@ class TestSources:
287
314
  assert (tmp_path / "conn" / "sources").is_dir()
288
315
  assert read_config_directory(tmp_path / "conn")["sources"] == []
289
316
 
317
+ def test_a_sources_directory_holding_only_the_placeholder_reads_empty(self, tmp_path):
318
+ """What a clone of a committed "no sources" directory looks like."""
319
+ conn = tmp_path / "conn"
320
+ write_config_directory(conn, WITHOUT_SOURCES)
321
+ (conn / "sources").mkdir()
322
+ (conn / "sources" / ".gitkeep").touch()
323
+
324
+ assert read_config_directory(conn)["sources"] == []
325
+
290
326
  def test_an_empty_sources_directory_survives_a_commit(self, tmp_path):
291
327
  """git carries no empty directory, and the directory IS the key here.
292
328
 
293
- Without a file in it, pull -> commit -> clone -> push would silently
294
- drop the key and put the connector back on the legacy path.
329
+ Without a file in it, pull -> commit -> clone -> push would find no
330
+ `sources/` and refuse a directory that declared `sources: []`.
295
331
  """
296
332
  write_config_directory(tmp_path / "conn", {**FULL_CONFIG, "sources": []})
297
333
 
@@ -316,14 +352,16 @@ class TestSources:
316
352
  "hubspot"
317
353
  ]
318
354
 
319
- def test_dropping_the_key_removes_the_directory(self, tmp_path):
320
- """Otherwise the next read would declare the key the config no longer has."""
355
+ def test_writing_a_version_without_the_key_removes_the_directory(self, tmp_path):
356
+ """Otherwise the next read would declare `sources: []` for a version
357
+ that says nothing about sources; without it, the read refuses instead."""
321
358
  write_config_directory(tmp_path / "conn", {**FULL_CONFIG, "sources": THREE_SOURCES})
322
359
 
323
- write_config_directory(tmp_path / "conn", FULL_CONFIG)
360
+ write_config_directory(tmp_path / "conn", WITHOUT_SOURCES)
324
361
 
325
362
  assert not (tmp_path / "conn" / "sources").exists()
326
- assert "sources" not in read_config_directory(tmp_path / "conn")
363
+ with pytest.raises(ConfigDirectoryError, match="does not declare 'sources'"):
364
+ read_config_directory(tmp_path / "conn")
327
365
 
328
366
  def test_sources_written_inline_by_an_older_cli_are_read(self, tmp_path):
329
367
  """0.5.0 and earlier round-trip `sources` as an opaque top-level key."""
@@ -360,22 +398,19 @@ class TestSources:
360
398
  with pytest.raises(ConfigDirectoryError, match="sources/"):
361
399
  read_config_directory(conn)
362
400
 
363
- def test_a_null_key_reads_as_an_absent_one(self, tmp_path):
401
+ def test_a_null_key_is_refused_like_an_absent_one(self, tmp_path):
364
402
  """`sources:` with nothing after it is not a declaration.
365
403
 
366
404
  Read as an empty list it would turn a config that says nothing about
367
- sources into one that declares the connector has none, which is the
368
- opposite meaning. `canonical_config` already treats null as absent, so
369
- reading it as `[]` also made the two disagree on the same document.
405
+ sources into one that declares the connector has none: the same guess
406
+ the CLI refuses to make for a missing `sources/` directory.
370
407
  """
371
408
  conn = tmp_path / "conn"
372
409
  conn.mkdir()
373
410
  (conn / "plugsync.yaml").write_text("version: '3.1'\nsources:\n")
374
411
 
375
- config = read_config_directory(conn)
376
-
377
- assert "sources" not in config
378
- assert "sources" not in canonical_config(config)
412
+ with pytest.raises(ConfigDirectoryError, match="does not declare 'sources'"):
413
+ read_config_directory(conn)
379
414
 
380
415
  def test_an_unnamed_source_file_is_refused(self, tmp_path):
381
416
  conn = tmp_path / "conn"
File without changes
File without changes