confpub-cli 1.4.2__tar.gz → 1.4.4__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.
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/PKG-INFO +1 -1
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/__init__.py +1 -1
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/cli.py +40 -13
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/config.py +31 -1
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/confluence.py +9 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/errors.py +28 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/guide.py +15 -2
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_integration.py +104 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/.github/workflows/publish.yml +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/.gitignore +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/CLAUDE.md +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/LICENSE +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/PRD.md +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/README.md +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/applier.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/assets.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/converter.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/envelope.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/lockfile.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/manifest.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/output.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/planner.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/publish.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/puller.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/py.typed +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/reverse_converter.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/validator.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub/verifier.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/confpub.lock +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/pyproject.toml +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/__init__.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/conftest.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_applier.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_assets.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_config.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_confluence.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_converter.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_envelope.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_errors.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_guide.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_lockfile.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_manifest.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_output.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_planner.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_publish.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_puller.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_validator.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/tests/test_verifier.py +0 -0
- {confpub_cli-1.4.2 → confpub_cli-1.4.4}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confpub-cli
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.4
|
|
4
4
|
Summary: Agent-first CLI to publish Markdown to Confluence
|
|
5
5
|
Project-URL: Homepage, https://github.com/ThomasRohde/confpub-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/ThomasRohde/confpub-cli.git
|
|
@@ -13,11 +13,30 @@ from typing import Any, Iterator, Optional
|
|
|
13
13
|
|
|
14
14
|
import typer
|
|
15
15
|
|
|
16
|
+
import os
|
|
17
|
+
|
|
16
18
|
from confpub import __version__
|
|
17
19
|
from confpub.envelope import Envelope
|
|
18
20
|
from confpub.errors import ConfpubError, exit_code_for, ERR_INTERNAL_SDK
|
|
19
21
|
from confpub.output import emit_stderr, emit_stdout, is_compact, is_verbose, set_compact, set_quiet, set_verbose
|
|
20
22
|
|
|
23
|
+
|
|
24
|
+
def _resolve_space(cli_space: str | None, required: bool = False) -> str | None:
|
|
25
|
+
"""Resolve space from CLI flag or CONFPUB_SPACE env var, with validation."""
|
|
26
|
+
from confpub.config import ENV_SPACE
|
|
27
|
+
from confpub.errors import validate_space_key
|
|
28
|
+
|
|
29
|
+
space = cli_space or os.environ.get(ENV_SPACE)
|
|
30
|
+
if space is not None:
|
|
31
|
+
validate_space_key(space)
|
|
32
|
+
return space
|
|
33
|
+
if required:
|
|
34
|
+
raise ConfpubError(
|
|
35
|
+
"ERR_VALIDATION_REQUIRED",
|
|
36
|
+
"Space key is required. Use --space or set CONFPUB_SPACE.",
|
|
37
|
+
)
|
|
38
|
+
return None
|
|
39
|
+
|
|
21
40
|
# ---------------------------------------------------------------------------
|
|
22
41
|
# Subcommand group apps
|
|
23
42
|
# ---------------------------------------------------------------------------
|
|
@@ -197,12 +216,14 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
197
216
|
|
|
198
217
|
@page_app.command("list")
|
|
199
218
|
def page_list(
|
|
200
|
-
space: str = typer.Option(
|
|
219
|
+
space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
|
|
201
220
|
limit: int = typer.Option(25, "--limit", help="Maximum number of pages to return"),
|
|
202
221
|
start: int = typer.Option(0, "--start", help="Starting offset for pagination"),
|
|
203
222
|
) -> None:
|
|
204
223
|
"""List pages in a Confluence space."""
|
|
205
|
-
with command_context("page.list"
|
|
224
|
+
with command_context("page.list") as ctx:
|
|
225
|
+
space = _resolve_space(space, required=True)
|
|
226
|
+
ctx.target = {"space": space}
|
|
206
227
|
from confpub.confluence import build_client, _slim_page
|
|
207
228
|
client = build_client()
|
|
208
229
|
ctx.client = client
|
|
@@ -218,7 +239,7 @@ def page_list(
|
|
|
218
239
|
|
|
219
240
|
@page_app.command("inspect")
|
|
220
241
|
def page_inspect(
|
|
221
|
-
space: str = typer.Option(None, "--space", help="Confluence space key"),
|
|
242
|
+
space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
|
|
222
243
|
title: str = typer.Option(None, "--title", help="Page title"),
|
|
223
244
|
page_id: str = typer.Option(None, "--page-id", help="Confluence page ID"),
|
|
224
245
|
raw: bool = typer.Option(False, "--raw", help="Return full raw API response"),
|
|
@@ -226,6 +247,7 @@ def page_inspect(
|
|
|
226
247
|
) -> None:
|
|
227
248
|
"""Inspect a Confluence page."""
|
|
228
249
|
with command_context("page.inspect", target={"space": space, "title": title, "page_id": page_id}) as ctx:
|
|
250
|
+
space = _resolve_space(space)
|
|
229
251
|
from confpub.confluence import build_client, _slim_page
|
|
230
252
|
client = build_client()
|
|
231
253
|
ctx.client = client
|
|
@@ -258,7 +280,7 @@ def page_inspect(
|
|
|
258
280
|
@page_app.command("publish")
|
|
259
281
|
def page_publish(
|
|
260
282
|
file: str = typer.Argument(..., help="Markdown file to publish"),
|
|
261
|
-
space: str = typer.Option(
|
|
283
|
+
space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
|
|
262
284
|
parent: Optional[str] = typer.Option(None, "--parent", help="Parent page title"),
|
|
263
285
|
title: Optional[str] = typer.Option(None, "--title", help="Page title (defaults to filename stem, hyphen/underscore→spaces, title-cased)"),
|
|
264
286
|
title_from_h1: bool = typer.Option(False, "--title-from-h1", help="Derive title from first H1 heading in the Markdown file"),
|
|
@@ -274,6 +296,8 @@ def page_publish(
|
|
|
274
296
|
if page_id:
|
|
275
297
|
target["page_id"] = page_id
|
|
276
298
|
with command_context("page.publish", target=target) as ctx:
|
|
299
|
+
space = _resolve_space(space, required=True)
|
|
300
|
+
ctx.target["space"] = space
|
|
277
301
|
if not page_id and not parent:
|
|
278
302
|
raise ConfpubError(
|
|
279
303
|
"ERR_VALIDATION_REQUIRED",
|
|
@@ -296,7 +320,7 @@ def page_publish(
|
|
|
296
320
|
|
|
297
321
|
@page_app.command("pull")
|
|
298
322
|
def page_pull(
|
|
299
|
-
space: str = typer.Option(None, "--space", help="Confluence space key"),
|
|
323
|
+
space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
|
|
300
324
|
title: str = typer.Option(None, "--title", help="Page title"),
|
|
301
325
|
page_id: str = typer.Option(None, "--page-id", help="Confluence page ID"),
|
|
302
326
|
output: str = typer.Option(".", "--output", "-o", help="Output directory"),
|
|
@@ -307,8 +331,8 @@ def page_pull(
|
|
|
307
331
|
manifest: bool = typer.Option(False, "--manifest", help="Generate confpub.yaml manifest"),
|
|
308
332
|
) -> None:
|
|
309
333
|
"""Pull Confluence pages to local Markdown files."""
|
|
310
|
-
target
|
|
311
|
-
|
|
334
|
+
with command_context("page.pull", target={"space": space, "title": title, "page_id": page_id}) as ctx:
|
|
335
|
+
space = _resolve_space(space)
|
|
312
336
|
from confpub.errors import ERR_VALIDATION_REQUIRED
|
|
313
337
|
if not page_id and not (space and title):
|
|
314
338
|
raise ConfpubError(
|
|
@@ -333,13 +357,14 @@ def page_pull(
|
|
|
333
357
|
|
|
334
358
|
@page_app.command("delete")
|
|
335
359
|
def page_delete(
|
|
336
|
-
space: Optional[str] = typer.Option(None, "--space", help="Confluence space key"),
|
|
360
|
+
space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
|
|
337
361
|
title: Optional[str] = typer.Option(None, "--title", help="Page title"),
|
|
338
362
|
page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID"),
|
|
339
363
|
cascade: bool = typer.Option(False, "--cascade", help="Also delete child pages"),
|
|
340
364
|
) -> None:
|
|
341
365
|
"""Delete a Confluence page."""
|
|
342
366
|
with command_context("page.delete", target={"space": space, "title": title, "page_id": page_id}) as ctx:
|
|
367
|
+
space = _resolve_space(space)
|
|
343
368
|
if not page_id and not (space and title):
|
|
344
369
|
raise ConfpubError(
|
|
345
370
|
"ERR_VALIDATION_REQUIRED",
|
|
@@ -384,12 +409,13 @@ def page_delete(
|
|
|
384
409
|
def page_move(
|
|
385
410
|
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID to move"),
|
|
386
411
|
target_parent: Optional[str] = typer.Option(None, "--target-parent", help="Title of the new parent page"),
|
|
387
|
-
space: Optional[str] = typer.Option(None, "--space", help="
|
|
412
|
+
space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
|
|
388
413
|
target_parent_id: Optional[str] = typer.Option(None, "--target-parent-id", help="Page ID of the new parent"),
|
|
389
414
|
) -> None:
|
|
390
415
|
"""Move a page under a new parent."""
|
|
391
416
|
target = {"page_id": page_id}
|
|
392
417
|
with command_context("page.move", target=target) as ctx:
|
|
418
|
+
space = _resolve_space(space)
|
|
393
419
|
if not target_parent and not target_parent_id:
|
|
394
420
|
raise ConfpubError(
|
|
395
421
|
"ERR_VALIDATION_REQUIRED",
|
|
@@ -460,11 +486,12 @@ def attachment_upload(
|
|
|
460
486
|
def plan_create(
|
|
461
487
|
manifest: str = typer.Option(..., "--manifest", help="Path to confpub.yaml manifest"),
|
|
462
488
|
output: Optional[str] = typer.Option(None, "--output", help="Output path for plan artifact"),
|
|
463
|
-
space: Optional[str] = typer.Option(None, "--space", help="
|
|
489
|
+
space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
|
|
464
490
|
parent: Optional[str] = typer.Option(None, "--parent", help="Override manifest parent"),
|
|
465
491
|
) -> None:
|
|
466
492
|
"""Generate a plan artifact from a manifest."""
|
|
467
493
|
with command_context("plan.create", target={"manifest": manifest}) as ctx:
|
|
494
|
+
space = _resolve_space(space)
|
|
468
495
|
from confpub.planner import create_plan
|
|
469
496
|
result = create_plan(
|
|
470
497
|
manifest_path=manifest,
|
|
@@ -668,7 +695,7 @@ def comment_add(
|
|
|
668
695
|
@app.command("search")
|
|
669
696
|
def search(
|
|
670
697
|
cql: Optional[str] = typer.Option(None, "--cql", help="Raw CQL query"),
|
|
671
|
-
space: Optional[str] = typer.Option(None, "--space", help="
|
|
698
|
+
space: Optional[str] = typer.Option(None, "--space", help="Confluence space key (or CONFPUB_SPACE env var)"),
|
|
672
699
|
title: Optional[str] = typer.Option(None, "--title", help="Search by page title (fuzzy match)"),
|
|
673
700
|
content_type: Optional[str] = typer.Option(None, "--type", help="Filter by content type (page, blogpost, etc.)"),
|
|
674
701
|
limit: int = typer.Option(25, "--limit", help="Maximum results to return"),
|
|
@@ -677,8 +704,8 @@ def search(
|
|
|
677
704
|
excerpt_length: int = typer.Option(200, "--excerpt-length", help="Max excerpt chars (0 = unlimited)"),
|
|
678
705
|
) -> None:
|
|
679
706
|
"""Search Confluence content using CQL."""
|
|
680
|
-
target
|
|
681
|
-
|
|
707
|
+
with command_context("search", target={"cql": cql, "space": space, "title": title, "type": content_type}) as ctx:
|
|
708
|
+
space = _resolve_space(space)
|
|
682
709
|
# Build effective CQL from flags
|
|
683
710
|
fragments: list[str] = []
|
|
684
711
|
if space:
|
|
@@ -22,6 +22,8 @@ CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
|
22
22
|
ENV_URL = "CONFPUB_URL"
|
|
23
23
|
ENV_TOKEN = "CONFPUB_TOKEN"
|
|
24
24
|
ENV_USER = "CONFPUB_USER"
|
|
25
|
+
ENV_SSL_VERIFY = "CONFPUB_SSL_VERIFY"
|
|
26
|
+
ENV_SPACE = "CONFPUB_SPACE"
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
class ConfigModel(BaseModel):
|
|
@@ -30,6 +32,7 @@ class ConfigModel(BaseModel):
|
|
|
30
32
|
base_url: Optional[str] = None
|
|
31
33
|
user: Optional[str] = None
|
|
32
34
|
token: Optional[str] = None
|
|
35
|
+
ssl_verify: Optional[str] = None
|
|
33
36
|
|
|
34
37
|
|
|
35
38
|
class ResolvedConfig:
|
|
@@ -41,11 +44,13 @@ class ResolvedConfig:
|
|
|
41
44
|
user: str | None = None,
|
|
42
45
|
token: str | None = None,
|
|
43
46
|
token_source: str | None = None,
|
|
47
|
+
ssl_verify: bool | str = False,
|
|
44
48
|
) -> None:
|
|
45
49
|
self.base_url = base_url
|
|
46
50
|
self.user = user
|
|
47
51
|
self.token = token
|
|
48
52
|
self.token_source = token_source
|
|
53
|
+
self.ssl_verify = ssl_verify
|
|
49
54
|
|
|
50
55
|
@property
|
|
51
56
|
def is_cloud(self) -> bool:
|
|
@@ -128,10 +133,28 @@ def _try_keyring(service: str, username: str) -> str | None:
|
|
|
128
133
|
return None
|
|
129
134
|
|
|
130
135
|
|
|
136
|
+
def _resolve_ssl_verify(raw: str | None) -> bool | str:
|
|
137
|
+
"""Parse an ssl_verify value into bool or CA-bundle path.
|
|
138
|
+
|
|
139
|
+
Accepts "true"/"false" (case-insensitive) or a filesystem path.
|
|
140
|
+
Returns False (default) when *raw* is None or empty.
|
|
141
|
+
"""
|
|
142
|
+
if not raw:
|
|
143
|
+
return False
|
|
144
|
+
lower = raw.strip().lower()
|
|
145
|
+
if lower == "true":
|
|
146
|
+
return True
|
|
147
|
+
if lower == "false":
|
|
148
|
+
return False
|
|
149
|
+
# Treat as CA bundle path
|
|
150
|
+
return raw.strip()
|
|
151
|
+
|
|
152
|
+
|
|
131
153
|
def load_config(
|
|
132
154
|
cli_url: str | None = None,
|
|
133
155
|
cli_user: str | None = None,
|
|
134
156
|
cli_token: str | None = None,
|
|
157
|
+
cli_ssl_verify: str | None = None,
|
|
135
158
|
) -> ResolvedConfig:
|
|
136
159
|
"""Resolve config using precedence: CLI → env → file → keychain."""
|
|
137
160
|
file_cfg = _load_config_file()
|
|
@@ -143,6 +166,10 @@ def load_config(
|
|
|
143
166
|
# Token
|
|
144
167
|
token = cli_token or os.environ.get(ENV_TOKEN) or file_cfg.token
|
|
145
168
|
|
|
169
|
+
# SSL verification
|
|
170
|
+
ssl_raw = cli_ssl_verify or os.environ.get(ENV_SSL_VERIFY) or file_cfg.ssl_verify
|
|
171
|
+
ssl_verify = _resolve_ssl_verify(ssl_raw)
|
|
172
|
+
|
|
146
173
|
# Determine source
|
|
147
174
|
token_source = None
|
|
148
175
|
if cli_token:
|
|
@@ -164,6 +191,7 @@ def load_config(
|
|
|
164
191
|
user=user,
|
|
165
192
|
token=token,
|
|
166
193
|
token_source=token_source,
|
|
194
|
+
ssl_verify=ssl_verify,
|
|
167
195
|
)
|
|
168
196
|
|
|
169
197
|
|
|
@@ -178,11 +206,13 @@ def set_config_value(key: str, value: str) -> None:
|
|
|
178
206
|
cfg.user = value
|
|
179
207
|
elif key == "token":
|
|
180
208
|
cfg.token = value
|
|
209
|
+
elif key == "ssl_verify":
|
|
210
|
+
cfg.ssl_verify = value
|
|
181
211
|
else:
|
|
182
212
|
from confpub.errors import ERR_VALIDATION_REQUIRED, validation_error
|
|
183
213
|
raise validation_error(
|
|
184
214
|
ERR_VALIDATION_REQUIRED,
|
|
185
|
-
f"Unknown config key: {key}. Valid keys: base_url, user, token",
|
|
215
|
+
f"Unknown config key: {key}. Valid keys: base_url, user, token, ssl_verify",
|
|
186
216
|
)
|
|
187
217
|
|
|
188
218
|
CONFIG_FILE.write_text(json.dumps(cfg.model_dump(exclude_none=True), indent=2), encoding="utf-8")
|
|
@@ -48,6 +48,15 @@ class ConfluenceClient:
|
|
|
48
48
|
kwargs["cloud"] = True
|
|
49
49
|
else:
|
|
50
50
|
kwargs["token"] = config.token
|
|
51
|
+
|
|
52
|
+
kwargs["verify_ssl"] = config.ssl_verify
|
|
53
|
+
|
|
54
|
+
# Suppress noisy per-request InsecureRequestWarning when SSL
|
|
55
|
+
# verification is disabled (common in corporate environments).
|
|
56
|
+
if not config.ssl_verify:
|
|
57
|
+
import urllib3
|
|
58
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
59
|
+
|
|
51
60
|
return Confluence(**kwargs)
|
|
52
61
|
|
|
53
62
|
def _handle_error(self, exc: Exception, context: str = "") -> None:
|
|
@@ -16,6 +16,7 @@ ERR_VALIDATION_ASSET_MISSING = "ERR_VALIDATION_ASSET_MISSING"
|
|
|
16
16
|
ERR_VALIDATION_SPACE_MISMATCH = "ERR_VALIDATION_SPACE_MISMATCH"
|
|
17
17
|
ERR_VALIDATION_NOT_FOUND = "ERR_VALIDATION_NOT_FOUND"
|
|
18
18
|
ERR_VALIDATION_LABEL = "ERR_VALIDATION_LABEL"
|
|
19
|
+
ERR_VALIDATION_SPACE_KEY = "ERR_VALIDATION_SPACE_KEY"
|
|
19
20
|
|
|
20
21
|
# Auth (exit 20)
|
|
21
22
|
ERR_AUTH_REQUIRED = "ERR_AUTH_REQUIRED"
|
|
@@ -169,6 +170,33 @@ def io_error(
|
|
|
169
170
|
return ConfpubError(code, message, details=details if details else None)
|
|
170
171
|
|
|
171
172
|
|
|
173
|
+
def validate_space_key(space: str | None) -> None:
|
|
174
|
+
"""Reject space values that look like shell-expanded paths.
|
|
175
|
+
|
|
176
|
+
PowerShell expands unquoted ``~username`` to a Windows home path
|
|
177
|
+
(e.g. ``C:\\Users\\username``). Catching this early gives the caller an
|
|
178
|
+
actionable error instead of a confusing Confluence API failure.
|
|
179
|
+
"""
|
|
180
|
+
if space is None:
|
|
181
|
+
return
|
|
182
|
+
import re
|
|
183
|
+
if "\\" in space or "/" in space or re.match(r"^[A-Za-z]:", space):
|
|
184
|
+
raise ConfpubError(
|
|
185
|
+
ERR_VALIDATION_SPACE_KEY,
|
|
186
|
+
(
|
|
187
|
+
f"Space key '{space}' appears to be a shell-expanded path. "
|
|
188
|
+
"Quote the value: --space '~username' or set CONFPUB_SPACE=~username"
|
|
189
|
+
),
|
|
190
|
+
details={
|
|
191
|
+
"fix_options": [
|
|
192
|
+
"Quote the --space value: --space '~username'",
|
|
193
|
+
"Set the CONFPUB_SPACE environment variable instead",
|
|
194
|
+
"Use the space key without shell-expandable characters",
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
172
200
|
def internal_error(
|
|
173
201
|
code: str = ERR_INTERNAL_CONVERTER,
|
|
174
202
|
message: str = "Internal error",
|
|
@@ -25,6 +25,7 @@ from confpub.errors import (
|
|
|
25
25
|
ERR_IO_TIMEOUT,
|
|
26
26
|
ERR_VALIDATION_ASSET_MISSING,
|
|
27
27
|
ERR_VALIDATION_LABEL,
|
|
28
|
+
ERR_VALIDATION_SPACE_KEY,
|
|
28
29
|
ERR_VALIDATION_MANIFEST,
|
|
29
30
|
ERR_VALIDATION_MARKDOWN,
|
|
30
31
|
ERR_VALIDATION_NOT_FOUND,
|
|
@@ -102,7 +103,9 @@ def build_guide() -> dict[str, Any]:
|
|
|
102
103
|
},
|
|
103
104
|
"agent_hint": (
|
|
104
105
|
"Use --start and --limit for pagination: first call with --start 0 --limit 25, "
|
|
105
|
-
"then if has_more is true, call again with --start 25 --limit 25, and so on."
|
|
106
|
+
"then if has_more is true, call again with --start 25 --limit 25, and so on. "
|
|
107
|
+
"For personal spaces, quote the tilde: --space '~username' "
|
|
108
|
+
"(PowerShell expands unquoted ~). Or set CONFPUB_SPACE env var."
|
|
106
109
|
),
|
|
107
110
|
},
|
|
108
111
|
"page.inspect": {
|
|
@@ -140,7 +143,9 @@ def build_guide() -> dict[str, Any]:
|
|
|
140
143
|
"the stem is extracted, hyphens and underscores are replaced with spaces, "
|
|
141
144
|
"and the result is title-cased. E.g. 'my-cool-page.md' → 'My Cool Page'. "
|
|
142
145
|
"Use --title-from-h1 to extract the title from the first # heading in the file. "
|
|
143
|
-
"Use --label to apply labels (repeatable): --label api --label docs."
|
|
146
|
+
"Use --label to apply labels (repeatable): --label api --label docs. "
|
|
147
|
+
"For personal spaces, quote the tilde: --space '~username' "
|
|
148
|
+
"(PowerShell expands unquoted ~). Or set CONFPUB_SPACE env var."
|
|
144
149
|
),
|
|
145
150
|
},
|
|
146
151
|
"page.move": {
|
|
@@ -307,6 +312,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
307
312
|
ERR_VALIDATION_NOT_FOUND: _error_code_entry(ERR_VALIDATION_NOT_FOUND),
|
|
308
313
|
ERR_VALIDATION_SPACE_MISMATCH: _error_code_entry(ERR_VALIDATION_SPACE_MISMATCH),
|
|
309
314
|
ERR_VALIDATION_LABEL: _error_code_entry(ERR_VALIDATION_LABEL),
|
|
315
|
+
ERR_VALIDATION_SPACE_KEY: _error_code_entry(ERR_VALIDATION_SPACE_KEY),
|
|
310
316
|
ERR_AUTH_REQUIRED: _error_code_entry(ERR_AUTH_REQUIRED),
|
|
311
317
|
ERR_AUTH_EXPIRED: _error_code_entry(ERR_AUTH_EXPIRED),
|
|
312
318
|
ERR_AUTH_FORBIDDEN: _error_code_entry(ERR_AUTH_FORBIDDEN),
|
|
@@ -405,6 +411,13 @@ def build_guide() -> dict[str, Any]:
|
|
|
405
411
|
"config_file",
|
|
406
412
|
"os_keychain",
|
|
407
413
|
],
|
|
414
|
+
"env_vars": {
|
|
415
|
+
"CONFPUB_URL": "Confluence base URL",
|
|
416
|
+
"CONFPUB_TOKEN": "API token or PAT",
|
|
417
|
+
"CONFPUB_USER": "User email or username",
|
|
418
|
+
"CONFPUB_SSL_VERIFY": "SSL verification (true/false/ca-bundle path)",
|
|
419
|
+
"CONFPUB_SPACE": "Default space key (avoids shell expansion issues with --space)",
|
|
420
|
+
},
|
|
408
421
|
"non_interactive": (
|
|
409
422
|
"Never prompts when LLM=true or stdin is non-interactive"
|
|
410
423
|
),
|
|
@@ -125,6 +125,110 @@ class TestPersonalSpaceKeyCLI:
|
|
|
125
125
|
assert captured["space"] == "~thro"
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
class TestSpaceKeyValidation:
|
|
129
|
+
"""Reject space values that look like shell-expanded paths."""
|
|
130
|
+
|
|
131
|
+
def test_windows_path_detected(self):
|
|
132
|
+
result = runner.invoke(app, ["page", "list", "--space", "C:\\Users\\thro"])
|
|
133
|
+
assert result.exit_code == 10
|
|
134
|
+
data = json.loads(result.output)
|
|
135
|
+
assert data["ok"] is False
|
|
136
|
+
assert data["errors"][0]["code"] == "ERR_VALIDATION_SPACE_KEY"
|
|
137
|
+
|
|
138
|
+
def test_unix_home_path_detected(self):
|
|
139
|
+
result = runner.invoke(app, ["page", "list", "--space", "/home/thro"])
|
|
140
|
+
assert result.exit_code == 10
|
|
141
|
+
data = json.loads(result.output)
|
|
142
|
+
assert data["ok"] is False
|
|
143
|
+
assert data["errors"][0]["code"] == "ERR_VALIDATION_SPACE_KEY"
|
|
144
|
+
|
|
145
|
+
def test_backslash_in_value_detected(self):
|
|
146
|
+
result = runner.invoke(app, ["page", "list", "--space", "some\\path"])
|
|
147
|
+
assert result.exit_code == 10
|
|
148
|
+
data = json.loads(result.output)
|
|
149
|
+
assert data["ok"] is False
|
|
150
|
+
assert data["errors"][0]["code"] == "ERR_VALIDATION_SPACE_KEY"
|
|
151
|
+
|
|
152
|
+
def test_valid_tilde_space_passes(self, monkeypatch):
|
|
153
|
+
def fake_list_pages(self, space, **kwargs):
|
|
154
|
+
return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
|
|
155
|
+
|
|
156
|
+
from confpub.confluence import ConfluenceClient
|
|
157
|
+
monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
|
|
158
|
+
monkeypatch.setattr("confpub.confluence.build_client", lambda: ConfluenceClient.__new__(ConfluenceClient))
|
|
159
|
+
|
|
160
|
+
result = runner.invoke(app, ["page", "list", "--space", "~thro"])
|
|
161
|
+
assert result.exit_code == 0
|
|
162
|
+
data = json.loads(result.output)
|
|
163
|
+
assert data["ok"] is True
|
|
164
|
+
|
|
165
|
+
def test_plain_space_key_passes(self, monkeypatch):
|
|
166
|
+
def fake_list_pages(self, space, **kwargs):
|
|
167
|
+
return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
|
|
168
|
+
|
|
169
|
+
from confpub.confluence import ConfluenceClient
|
|
170
|
+
monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
|
|
171
|
+
monkeypatch.setattr("confpub.confluence.build_client", lambda: ConfluenceClient.__new__(ConfluenceClient))
|
|
172
|
+
|
|
173
|
+
result = runner.invoke(app, ["page", "list", "--space", "DEV"])
|
|
174
|
+
assert result.exit_code == 0
|
|
175
|
+
data = json.loads(result.output)
|
|
176
|
+
assert data["ok"] is True
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class TestConfpubSpaceEnvVar:
|
|
180
|
+
"""CONFPUB_SPACE env var as an alternative to --space."""
|
|
181
|
+
|
|
182
|
+
def test_env_var_used_when_no_flag(self, monkeypatch):
|
|
183
|
+
def fake_list_pages(self, space, **kwargs):
|
|
184
|
+
self._captured_space = space
|
|
185
|
+
return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
|
|
186
|
+
|
|
187
|
+
from confpub.confluence import ConfluenceClient
|
|
188
|
+
mock_client = ConfluenceClient.__new__(ConfluenceClient)
|
|
189
|
+
monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
|
|
190
|
+
monkeypatch.setattr("confpub.confluence.build_client", lambda: mock_client)
|
|
191
|
+
monkeypatch.setenv("CONFPUB_SPACE", "~thro")
|
|
192
|
+
|
|
193
|
+
result = runner.invoke(app, ["page", "list"])
|
|
194
|
+
assert result.exit_code == 0
|
|
195
|
+
data = json.loads(result.output)
|
|
196
|
+
assert data["ok"] is True
|
|
197
|
+
assert mock_client._captured_space == "~thro"
|
|
198
|
+
|
|
199
|
+
def test_cli_flag_overrides_env(self, monkeypatch):
|
|
200
|
+
captured = {}
|
|
201
|
+
|
|
202
|
+
def fake_list_pages(self, space, **kwargs):
|
|
203
|
+
captured["space"] = space
|
|
204
|
+
return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
|
|
205
|
+
|
|
206
|
+
from confpub.confluence import ConfluenceClient
|
|
207
|
+
monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
|
|
208
|
+
monkeypatch.setattr("confpub.confluence.build_client", lambda: ConfluenceClient.__new__(ConfluenceClient))
|
|
209
|
+
monkeypatch.setenv("CONFPUB_SPACE", "~env")
|
|
210
|
+
|
|
211
|
+
result = runner.invoke(app, ["page", "list", "--space", "~cli"])
|
|
212
|
+
assert result.exit_code == 0
|
|
213
|
+
assert captured["space"] == "~cli"
|
|
214
|
+
|
|
215
|
+
def test_env_var_also_validated(self, monkeypatch):
|
|
216
|
+
monkeypatch.setenv("CONFPUB_SPACE", "C:\\Users\\thro")
|
|
217
|
+
|
|
218
|
+
result = runner.invoke(app, ["page", "list"])
|
|
219
|
+
assert result.exit_code == 10
|
|
220
|
+
data = json.loads(result.output)
|
|
221
|
+
assert data["ok"] is False
|
|
222
|
+
assert data["errors"][0]["code"] == "ERR_VALIDATION_SPACE_KEY"
|
|
223
|
+
|
|
224
|
+
def test_missing_space_mentions_env(self):
|
|
225
|
+
result = runner.invoke(app, ["page", "list"])
|
|
226
|
+
assert result.exit_code == 10
|
|
227
|
+
data = json.loads(result.output)
|
|
228
|
+
assert data["ok"] is False
|
|
229
|
+
assert "CONFPUB_SPACE" in data["errors"][0]["message"]
|
|
230
|
+
|
|
231
|
+
|
|
128
232
|
class TestSearchCommand:
|
|
129
233
|
def test_search_help(self):
|
|
130
234
|
result = runner.invoke(app, ["search", "--help"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|