ocmo-cli 0.8.19__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.
- LICENSE +201 -0
- NOTICE +16 -0
- ocmo_cli/__init__.py +3 -0
- ocmo_cli/__main__.py +4 -0
- ocmo_cli/_address.py +182 -0
- ocmo_cli/_audit_item.py +33 -0
- ocmo_cli/_auth_resolve.py +66 -0
- ocmo_cli/_body_payload.py +203 -0
- ocmo_cli/_click_groups.py +227 -0
- ocmo_cli/_client.py +351 -0
- ocmo_cli/_client_commands.py +35 -0
- ocmo_cli/_command_registry.py +29 -0
- ocmo_cli/_commands_map.py +65 -0
- ocmo_cli/_config.py +183 -0
- ocmo_cli/_config_content.py +43 -0
- ocmo_cli/_delete_item_output.py +129 -0
- ocmo_cli/_diff_input.py +144 -0
- ocmo_cli/_dry_run.py +292 -0
- ocmo_cli/_errors.py +151 -0
- ocmo_cli/_exit.py +13 -0
- ocmo_cli/_export_metadata.py +47 -0
- ocmo_cli/_generated_registry.py +45 -0
- ocmo_cli/_get_cast_output.py +52 -0
- ocmo_cli/_get_item_list.py +23 -0
- ocmo_cli/_globalpermission_output.py +98 -0
- ocmo_cli/_import_metadata.py +140 -0
- ocmo_cli/_import_plan.py +221 -0
- ocmo_cli/_item_output.py +223 -0
- ocmo_cli/_lazy_mixin.py +29 -0
- ocmo_cli/_ls_wide.py +286 -0
- ocmo_cli/_mutating.py +39 -0
- ocmo_cli/_notice.py +17 -0
- ocmo_cli/_options.py +77 -0
- ocmo_cli/_output.py +424 -0
- ocmo_cli/_output_manifest.py +332 -0
- ocmo_cli/_resolve_draft.py +50 -0
- ocmo_cli/_resolve_options.py +143 -0
- ocmo_cli/_resolve_output.py +228 -0
- ocmo_cli/_resolve_parameters_output.py +72 -0
- ocmo_cli/_resolve_series.py +89 -0
- ocmo_cli/_resolve_series_chart.py +409 -0
- ocmo_cli/_resolve_write.py +185 -0
- ocmo_cli/_resolver_output.py +34 -0
- ocmo_cli/_resource_aliases.py +22 -0
- ocmo_cli/_sdk_dispatch.py +310 -0
- ocmo_cli/_typing.py +27 -0
- ocmo_cli/_version_output.py +39 -0
- ocmo_cli/_whoami_output.py +43 -0
- ocmo_cli/commands/__init__.py +1 -0
- ocmo_cli/commands/api.py +125 -0
- ocmo_cli/commands/api_health.py +12 -0
- ocmo_cli/commands/apply.py +153 -0
- ocmo_cli/commands/auth.py +786 -0
- ocmo_cli/commands/can_i.py +51 -0
- ocmo_cli/commands/completion.py +59 -0
- ocmo_cli/commands/config_cmd.py +235 -0
- ocmo_cli/commands/describe.py +109 -0
- ocmo_cli/commands/diff.py +86 -0
- ocmo_cli/commands/edit.py +200 -0
- ocmo_cli/commands/export.py +242 -0
- ocmo_cli/commands/generated.py +907 -0
- ocmo_cli/commands/gp_move.py +108 -0
- ocmo_cli/commands/import_.py +595 -0
- ocmo_cli/commands/item_relocate.py +187 -0
- ocmo_cli/commands/ls.py +481 -0
- ocmo_cli/commands/resolve.py +658 -0
- ocmo_cli/commands/resolve_draft.py +102 -0
- ocmo_cli/commands/resolve_parameters.py +65 -0
- ocmo_cli/commands/resolve_series.py +109 -0
- ocmo_cli/commands/schema.py +112 -0
- ocmo_cli/commands/timeline.py +135 -0
- ocmo_cli/commands/version_cmd.py +102 -0
- ocmo_cli/commands/whoami.py +12 -0
- ocmo_cli/main.py +241 -0
- ocmo_cli/output_formats.yaml +295 -0
- ocmo_cli-0.8.19.dist-info/METADATA +169 -0
- ocmo_cli-0.8.19.dist-info/RECORD +81 -0
- ocmo_cli-0.8.19.dist-info/WHEEL +4 -0
- ocmo_cli-0.8.19.dist-info/entry_points.txt +2 -0
- ocmo_cli-0.8.19.dist-info/licenses/LICENSE +201 -0
- ocmo_cli-0.8.19.dist-info/licenses/NOTICE +16 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""ocmo edit — open an item in $EDITOR and update on save."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from .._address import parse_address_or_exit
|
|
14
|
+
from .._click_groups import ResourceAliasGroup
|
|
15
|
+
from .._client import OcmoCtx
|
|
16
|
+
from .._errors import sdk_command
|
|
17
|
+
from .._exit import USAGE_ERROR
|
|
18
|
+
from .._item_output import (
|
|
19
|
+
deleted_version_edit_error,
|
|
20
|
+
emit_item_result,
|
|
21
|
+
item_body,
|
|
22
|
+
item_output_format,
|
|
23
|
+
item_version_is_deleted,
|
|
24
|
+
node_type_of,
|
|
25
|
+
)
|
|
26
|
+
from .._options import namespace_option, output_option, tree_version_option
|
|
27
|
+
from .._output import err, status
|
|
28
|
+
from .._resource_aliases import RESOURCE_ALIASES
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from ocmo import NamespaceView
|
|
32
|
+
|
|
33
|
+
_EDIT_RESOURCES = ("config", "template", "secret", "resolver")
|
|
34
|
+
|
|
35
|
+
_EDIT_GROUP_HELP = """\
|
|
36
|
+
Open a tree item in $EDITOR and update it on save.
|
|
37
|
+
|
|
38
|
+
Fetches the current document body, opens it in $EDITOR (or $VISUAL, defaulting to vi),
|
|
39
|
+
then submits an update when the file changes."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.group("edit", cls=ResourceAliasGroup, help=_EDIT_GROUP_HELP)
|
|
43
|
+
def edit_group() -> None:
|
|
44
|
+
"""Edit tree document items interactively."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_edit_command(resource: str) -> click.Command:
|
|
48
|
+
output_key = f"edit {resource}"
|
|
49
|
+
resource_help = (
|
|
50
|
+
f"Edit a {resource} item. ADDRESS is the tree path, optionally with " "@version or @tag (e.g. app/web@stable)."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@click.command(resource, help=resource_help)
|
|
54
|
+
@click.argument("address")
|
|
55
|
+
@namespace_option()
|
|
56
|
+
@output_option(output_key)
|
|
57
|
+
@tree_version_option()
|
|
58
|
+
@click.pass_obj
|
|
59
|
+
@sdk_command
|
|
60
|
+
def edit_resource_cmd(
|
|
61
|
+
ctx: OcmoCtx,
|
|
62
|
+
address: str,
|
|
63
|
+
namespace: str | None,
|
|
64
|
+
output_fmt: str | None,
|
|
65
|
+
version_flag: str | None,
|
|
66
|
+
*,
|
|
67
|
+
_resource: str = resource,
|
|
68
|
+
) -> None:
|
|
69
|
+
_run_edit(
|
|
70
|
+
ctx=ctx,
|
|
71
|
+
resource=_resource,
|
|
72
|
+
address=address,
|
|
73
|
+
namespace=namespace,
|
|
74
|
+
output_fmt=output_fmt,
|
|
75
|
+
version_flag=version_flag,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return edit_resource_cmd
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
for _resource in _EDIT_RESOURCES:
|
|
82
|
+
edit_group.add_resource_command(
|
|
83
|
+
_build_edit_command(_resource),
|
|
84
|
+
canonical=_resource,
|
|
85
|
+
aliases=RESOURCE_ALIASES.get(_resource, []),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _run_edit(
|
|
90
|
+
*,
|
|
91
|
+
ctx: OcmoCtx,
|
|
92
|
+
resource: str,
|
|
93
|
+
address: str,
|
|
94
|
+
namespace: str | None,
|
|
95
|
+
output_fmt: str | None,
|
|
96
|
+
version_flag: str | None,
|
|
97
|
+
) -> None:
|
|
98
|
+
path, version = parse_address_or_exit(address, version_flag=version_flag)
|
|
99
|
+
ns = ctx.require_namespace(namespace)
|
|
100
|
+
view = ctx.namespace_view(namespace)
|
|
101
|
+
|
|
102
|
+
if ctx.dry_run:
|
|
103
|
+
from .._dry_run import emit_dry_run_plan, format_edit_dry_run # deferred
|
|
104
|
+
|
|
105
|
+
emit_dry_run_plan(
|
|
106
|
+
format_edit_dry_run(
|
|
107
|
+
resource=resource,
|
|
108
|
+
path=path,
|
|
109
|
+
namespace=ns,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
get_kwargs: dict[str, Any] = {}
|
|
115
|
+
if version is not None:
|
|
116
|
+
get_kwargs["version"] = version
|
|
117
|
+
if resource == "secret":
|
|
118
|
+
get_kwargs["reveal"] = True
|
|
119
|
+
|
|
120
|
+
item = view.get_item(path=path, **get_kwargs)
|
|
121
|
+
actual_type = node_type_of(item)
|
|
122
|
+
if actual_type and actual_type != resource:
|
|
123
|
+
err(f"Item {path!r} is a {actual_type}, not a {resource}.")
|
|
124
|
+
raise SystemExit(USAGE_ERROR)
|
|
125
|
+
|
|
126
|
+
if item_version_is_deleted(item):
|
|
127
|
+
err(deleted_version_edit_error(path, item))
|
|
128
|
+
raise SystemExit(USAGE_ERROR)
|
|
129
|
+
|
|
130
|
+
content = item_body(item, resource=resource)
|
|
131
|
+
editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") or "vi"
|
|
132
|
+
|
|
133
|
+
if resource == "secret":
|
|
134
|
+
new_content = _edit_in_secure_temp(content, editor)
|
|
135
|
+
else:
|
|
136
|
+
new_content = _edit_in_temp_file(content, editor, suffix=".yaml")
|
|
137
|
+
|
|
138
|
+
if new_content == content:
|
|
139
|
+
print("No changes.")
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
result = _update_item(view, path, new_content, resource)
|
|
143
|
+
status(f"Updated {resource} {path!r}.")
|
|
144
|
+
|
|
145
|
+
fmt = item_output_format(
|
|
146
|
+
output_fmt,
|
|
147
|
+
ctx.output,
|
|
148
|
+
command_key=f"edit {resource}",
|
|
149
|
+
)
|
|
150
|
+
emit_item_result(result, fmt, no_color=ctx.no_color, resource=resource)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _edit_in_temp_file(content: str, editor: str, *, suffix: str) -> str:
|
|
154
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as handle:
|
|
155
|
+
handle.write(content)
|
|
156
|
+
tmp_path = handle.name
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
_run_editor(editor, tmp_path)
|
|
160
|
+
with open(tmp_path, encoding="utf-8") as handle:
|
|
161
|
+
return handle.read()
|
|
162
|
+
finally:
|
|
163
|
+
os.unlink(tmp_path)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _edit_in_secure_temp(content: str, editor: str) -> str:
|
|
167
|
+
"""Edit a secret in a secure temp directory with 0700/0600 permissions."""
|
|
168
|
+
tmp_dir = tempfile.mkdtemp()
|
|
169
|
+
os.chmod(tmp_dir, 0o700)
|
|
170
|
+
tmp_path = os.path.join(tmp_dir, "secret")
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
174
|
+
os.chmod(tmp_path, 0o600)
|
|
175
|
+
handle.write(content)
|
|
176
|
+
|
|
177
|
+
_run_editor(editor, tmp_path)
|
|
178
|
+
|
|
179
|
+
with open(tmp_path, encoding="utf-8") as handle:
|
|
180
|
+
return handle.read()
|
|
181
|
+
finally:
|
|
182
|
+
if os.path.exists(tmp_path):
|
|
183
|
+
size = os.path.getsize(tmp_path)
|
|
184
|
+
with open(tmp_path, "wb") as handle:
|
|
185
|
+
handle.write(b"\x00" * size)
|
|
186
|
+
os.unlink(tmp_path)
|
|
187
|
+
os.rmdir(tmp_dir)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _run_editor(editor: str, path: str) -> None:
|
|
191
|
+
args = shlex.split(editor) + [path]
|
|
192
|
+
result = subprocess.run(args)
|
|
193
|
+
if result.returncode != 0:
|
|
194
|
+
err(f"Editor exited with code {result.returncode}.")
|
|
195
|
+
raise SystemExit(1)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _update_item(view: NamespaceView, path: str, content: str, resource: str) -> Any:
|
|
199
|
+
method = getattr(view, f"update_{resource}")
|
|
200
|
+
return method(path=path, content=content)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""ocmo export — write a namespace subtree to disk.
|
|
2
|
+
|
|
3
|
+
One file per item at its tree path. Secrets excluded unless --reveal-secrets.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from .._address import parse_address_or_exit
|
|
16
|
+
from .._client import OcmoCtx
|
|
17
|
+
from .._errors import sdk_command
|
|
18
|
+
from .._export_metadata import (
|
|
19
|
+
export_metadata_rows,
|
|
20
|
+
metadata_file_prefix,
|
|
21
|
+
write_export_xattrs,
|
|
22
|
+
)
|
|
23
|
+
from .._item_output import item_body
|
|
24
|
+
from .._options import yes_option
|
|
25
|
+
from .._output import as_dict, err, warn
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.command("export")
|
|
29
|
+
@click.argument("address")
|
|
30
|
+
@click.option("--to", "dest_dir", required=True, metavar="DIR", help="Target directory for exported files.")
|
|
31
|
+
@click.option("-n", "--namespace", default=None)
|
|
32
|
+
@click.option("--version", "-V", "version_flag", default=None, help="Version / tag to export (default: latest).")
|
|
33
|
+
@click.option(
|
|
34
|
+
"--reveal-secrets", is_flag=True, default=False, help="Include secrets with decrypted values. Requires --yes."
|
|
35
|
+
)
|
|
36
|
+
@click.option("--overwrite", is_flag=True, default=False, help="Overwrite existing files.")
|
|
37
|
+
@click.option(
|
|
38
|
+
"--fail-on-missing", is_flag=True, default=False, help="Exit non-zero if any item lacks the requested version/tag."
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--metadata", is_flag=True, default=False, help="Embed item metadata in exported files and xattrs (user.ocmo.*)."
|
|
42
|
+
)
|
|
43
|
+
@click.option("--dry-run", is_flag=True, default=False)
|
|
44
|
+
@yes_option()
|
|
45
|
+
@click.pass_obj
|
|
46
|
+
@sdk_command
|
|
47
|
+
def export_cmd(
|
|
48
|
+
ctx: OcmoCtx,
|
|
49
|
+
address: str,
|
|
50
|
+
dest_dir: str,
|
|
51
|
+
namespace: str | None,
|
|
52
|
+
version_flag: str | None,
|
|
53
|
+
reveal_secrets: bool,
|
|
54
|
+
overwrite: bool,
|
|
55
|
+
fail_on_missing: bool,
|
|
56
|
+
metadata: bool,
|
|
57
|
+
dry_run: bool,
|
|
58
|
+
yes: bool,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Export a subtree to disk, preserving tree structure.
|
|
61
|
+
|
|
62
|
+
\b
|
|
63
|
+
Examples:
|
|
64
|
+
ocmo -n prod export app/ --to ./backup/
|
|
65
|
+
ocmo -n prod export app/ --to ./backup/ --version stable
|
|
66
|
+
ocmo -n prod export app/ --to ./backup/ --reveal-secrets --yes
|
|
67
|
+
"""
|
|
68
|
+
path, version = parse_address_or_exit(address, version_flag=version_flag)
|
|
69
|
+
|
|
70
|
+
if reveal_secrets and not (yes or ctx.yes):
|
|
71
|
+
if sys.stdin.isatty():
|
|
72
|
+
warn("--reveal-secrets will export decrypted secret values to disk.")
|
|
73
|
+
from .._mutating import run_mutating
|
|
74
|
+
|
|
75
|
+
run_mutating(
|
|
76
|
+
ctx,
|
|
77
|
+
dry_run=False,
|
|
78
|
+
yes=False,
|
|
79
|
+
plan_lines=None,
|
|
80
|
+
confirm_message="Continue with revealed secrets?",
|
|
81
|
+
action=lambda: None,
|
|
82
|
+
abort_exit_code=1,
|
|
83
|
+
abort_via_err=True,
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
err("--reveal-secrets requires --yes in non-interactive mode.")
|
|
87
|
+
raise SystemExit(1)
|
|
88
|
+
|
|
89
|
+
ns = ctx.require_namespace(namespace)
|
|
90
|
+
dest = Path(dest_dir)
|
|
91
|
+
|
|
92
|
+
if not (dry_run or ctx.dry_run):
|
|
93
|
+
_check_dest_permissions(dest, reveal_secrets)
|
|
94
|
+
|
|
95
|
+
view = ctx.namespace_view(namespace)
|
|
96
|
+
result = view.navigate_path(path=path.rstrip("/"))
|
|
97
|
+
|
|
98
|
+
data = as_dict(result)
|
|
99
|
+
children = data.get("children") or []
|
|
100
|
+
|
|
101
|
+
items_to_export = _collect_items(children, include_secrets=reveal_secrets)
|
|
102
|
+
|
|
103
|
+
if not items_to_export:
|
|
104
|
+
warn(f"No items found under {path!r}.")
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
if dry_run or ctx.dry_run:
|
|
108
|
+
from .._dry_run import emit_dry_run_plan, format_export_dry_run # deferred
|
|
109
|
+
|
|
110
|
+
base = path.rstrip("/")
|
|
111
|
+
for item in items_to_export:
|
|
112
|
+
item_path = item.get("path") or item.get("name", "")
|
|
113
|
+
rel = _relative_export_path(item_path, base)
|
|
114
|
+
dest_file = _safe_join(dest, rel)
|
|
115
|
+
emit_dry_run_plan(
|
|
116
|
+
format_export_dry_run(
|
|
117
|
+
item_path=item_path,
|
|
118
|
+
dest_file=str(dest_file),
|
|
119
|
+
namespace=ns,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
skipped = failed = exported = 0
|
|
125
|
+
base = path.rstrip("/")
|
|
126
|
+
|
|
127
|
+
for item_meta in items_to_export:
|
|
128
|
+
item_path = item_meta.get("path") or item_meta.get("name", "")
|
|
129
|
+
node_type = item_meta.get("node_type") or item_meta.get("type", "config")
|
|
130
|
+
item_version = version or "latest"
|
|
131
|
+
|
|
132
|
+
rel = _relative_export_path(item_path, base)
|
|
133
|
+
dest_file = _safe_join(dest, rel)
|
|
134
|
+
|
|
135
|
+
if dest_file.exists() and not overwrite:
|
|
136
|
+
warn(f"Skipping {rel!r}: file exists (use --overwrite).")
|
|
137
|
+
skipped += 1
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
get_kwargs: dict[str, Any] = {"version": item_version}
|
|
142
|
+
if node_type == "secret" and reveal_secrets:
|
|
143
|
+
get_kwargs["reveal"] = True
|
|
144
|
+
|
|
145
|
+
raw_item = view.get_item(path=item_path, **get_kwargs)
|
|
146
|
+
content = item_body(raw_item)
|
|
147
|
+
meta_rows = export_metadata_rows(raw_item, namespace=ns) if metadata else []
|
|
148
|
+
if metadata:
|
|
149
|
+
content = metadata_file_prefix(meta_rows, node_type=node_type) + content
|
|
150
|
+
|
|
151
|
+
if not content and fail_on_missing:
|
|
152
|
+
err(f"Item {item_path!r} has no content at version {item_version!r}.")
|
|
153
|
+
raise SystemExit(1)
|
|
154
|
+
|
|
155
|
+
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
mode = 0o600 if node_type == "secret" else 0o644
|
|
157
|
+
_atomic_write_text(dest_file, content, mode=mode)
|
|
158
|
+
if metadata:
|
|
159
|
+
write_export_xattrs(dest_file, meta_rows)
|
|
160
|
+
|
|
161
|
+
if sys.stderr.isatty():
|
|
162
|
+
err(f"Exported: {rel}")
|
|
163
|
+
exported += 1
|
|
164
|
+
|
|
165
|
+
except SystemExit:
|
|
166
|
+
raise
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
if fail_on_missing and "not found" in str(exc).lower():
|
|
169
|
+
err(f"Item {item_path!r} not found at version {item_version!r}: {exc}")
|
|
170
|
+
raise SystemExit(1) from exc
|
|
171
|
+
warn(f"Failed to export {item_path!r}: {exc}")
|
|
172
|
+
failed += 1
|
|
173
|
+
|
|
174
|
+
print(f"Exported {exported} items, skipped {skipped}, failed {failed}.", file=sys.stderr)
|
|
175
|
+
if failed:
|
|
176
|
+
raise SystemExit(1)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _relative_export_path(item_path: str, base: str) -> str:
|
|
180
|
+
base = base.rstrip("/")
|
|
181
|
+
if not base:
|
|
182
|
+
return item_path
|
|
183
|
+
if item_path == base:
|
|
184
|
+
return ""
|
|
185
|
+
prefix = f"{base}/"
|
|
186
|
+
if item_path.startswith(prefix):
|
|
187
|
+
return item_path[len(prefix) :]
|
|
188
|
+
return item_path
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _collect_items(children: list[Any], *, include_secrets: bool) -> list[dict[str, Any]]:
|
|
192
|
+
"""Flatten tree children, optionally excluding secrets."""
|
|
193
|
+
result: list[dict[str, Any]] = []
|
|
194
|
+
for item in children:
|
|
195
|
+
node = item if isinstance(item, dict) else vars(item)
|
|
196
|
+
node_type = node.get("node_type") or node.get("type", "")
|
|
197
|
+
if node_type == "folder":
|
|
198
|
+
result.extend(_collect_items(node.get("children") or [], include_secrets=include_secrets))
|
|
199
|
+
elif node_type == "secret" and not include_secrets:
|
|
200
|
+
continue
|
|
201
|
+
else:
|
|
202
|
+
result.append(node)
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _check_dest_permissions(dest: Path, reveal_secrets: bool) -> None:
|
|
207
|
+
if not dest.exists():
|
|
208
|
+
return
|
|
209
|
+
if not reveal_secrets:
|
|
210
|
+
return
|
|
211
|
+
# Refuse to write revealed secrets into group/world-readable directories
|
|
212
|
+
mode = dest.stat().st_mode
|
|
213
|
+
import stat
|
|
214
|
+
|
|
215
|
+
if mode & (stat.S_IRGRP | stat.S_IROTH):
|
|
216
|
+
err(
|
|
217
|
+
f"Refusing to write revealed secrets into {dest}: "
|
|
218
|
+
"directory is group- or world-readable. "
|
|
219
|
+
"Run: chmod 700 " + str(dest)
|
|
220
|
+
)
|
|
221
|
+
raise SystemExit(1)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _safe_join(base: Path, name: str) -> Path:
|
|
225
|
+
if os.path.isabs(name):
|
|
226
|
+
raise ValueError(f"Absolute path in server response: {name!r}")
|
|
227
|
+
resolved = (base / name).resolve()
|
|
228
|
+
if not str(resolved).startswith(str(base.resolve())):
|
|
229
|
+
raise ValueError(f"Path traversal: {name!r} escapes {base}")
|
|
230
|
+
return base / name
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _atomic_write_text(path: Path, content: str, *, mode: int = 0o644) -> None:
|
|
234
|
+
tmp = Path(str(path) + ".tmp")
|
|
235
|
+
try:
|
|
236
|
+
with tmp.open("w") as f:
|
|
237
|
+
f.write(content)
|
|
238
|
+
os.chmod(tmp, mode)
|
|
239
|
+
tmp.replace(path)
|
|
240
|
+
except Exception:
|
|
241
|
+
tmp.unlink(missing_ok=True)
|
|
242
|
+
raise
|