fogies 0.0.0.dev11__tar.gz → 0.0.0.dev12__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.
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/PKG-INFO +2 -1
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/pyproject.toml +3 -1
- fogies-0.0.0.dev12/src/fogies/age_key.py +48 -0
- fogies-0.0.0.dev12/src/fogies/includes/templates/claude_sync.toml.template +16 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/access_key.py +5 -0
- fogies-0.0.0.dev12/src/fogies/tasks/claude_sync.py +145 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/format.py +3 -1
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/lint.py +3 -1
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/poetry.py +6 -2
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/terraform.py +52 -28
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/terraform_backend.py +13 -24
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/test.py +3 -1
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/templates.py +4 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/backend.py +9 -7
- fogies-0.0.0.dev12/src/fogies/tools/claude_sync.py +302 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/terraform.py +4 -2
- fogies-0.0.0.dev11/src/fogies/tools/claude_sync.py +0 -130
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/README.md +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/__init__.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/aws_access_key.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/boto_clients.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/download_verified.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/includes/templates/aws.toml.template +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/includes/templates/backend_status.toml.template +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/includes/templates/poetry.toml.template +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/py.typed +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/pyfogies.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/ready_poll.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/retry.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/__init__.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/__init__.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/alb.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/cloudwatch.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/ecr.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/ecs.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/hosted_zone.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/network.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/__init__.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/aws_environ.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/boto.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/command.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/environ.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/go.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/ollama.py +0 -0
- {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/terraform_backend.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fogies
|
|
3
|
-
Version: 0.0.0.
|
|
3
|
+
Version: 0.0.0.dev12
|
|
4
4
|
Summary:
|
|
5
5
|
Author: James Fogarty
|
|
6
6
|
Author-email: jayfo@users.noreply.github.com
|
|
@@ -12,6 +12,7 @@ Requires-Dist: filelock (>=3.25.2,<4.0.0)
|
|
|
12
12
|
Requires-Dist: ollama (>=0.6.2,<0.7.0)
|
|
13
13
|
Requires-Dist: psutil (>=7.2.2,<8.0.0)
|
|
14
14
|
Requires-Dist: pydantic (>=2.12.5,<3.0.0)
|
|
15
|
+
Requires-Dist: pyrage (>=1.4.0,<2.0.0)
|
|
15
16
|
Requires-Dist: requests (>=2.33.0,<2.34.0)
|
|
16
17
|
Requires-Dist: tenacity (>=9.1.4,<10.0.0)
|
|
17
18
|
Requires-Dist: tomlkit (>=0.15.1,<0.16.0)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fogies"
|
|
3
3
|
# Version follows semantic versioning <https://semver.org/>.
|
|
4
|
-
version = "0.0.0-dev.
|
|
4
|
+
version = "0.0.0-dev.12"
|
|
5
5
|
description = ""
|
|
6
6
|
authors = [
|
|
7
7
|
{name = "James Fogarty",email = "jayfo@users.noreply.github.com"}
|
|
@@ -18,6 +18,7 @@ include = [
|
|
|
18
18
|
{path = "src/fogies/includes/templates/aws.toml.template", format = ["sdist", "wheel"]},
|
|
19
19
|
{path = "src/fogies/includes/templates/poetry.toml.template", format = ["sdist", "wheel"]},
|
|
20
20
|
{path = "src/fogies/includes/templates/backend_status.toml.template", format = ["sdist", "wheel"]},
|
|
21
|
+
{path = "src/fogies/includes/templates/claude_sync.toml.template", format = ["sdist", "wheel"]},
|
|
21
22
|
]
|
|
22
23
|
|
|
23
24
|
[tool.poetry.dependencies]
|
|
@@ -33,6 +34,7 @@ pydantic = "^2.12.5"
|
|
|
33
34
|
requests = "~2.33.0"
|
|
34
35
|
tenacity = "^9.1.4"
|
|
35
36
|
tomlkit = "^0.15.1"
|
|
37
|
+
pyrage = "^1.4.0"
|
|
36
38
|
|
|
37
39
|
[tool.poetry.group.dev.dependencies]
|
|
38
40
|
# ^ allows all minor and patch releases.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Age secret keys, which encrypt what claude-sync uploads.
|
|
2
|
+
|
|
3
|
+
An age secret key is a string like AGE-SECRET-KEY-1...: see
|
|
4
|
+
<https://github.com/FiloSottile/age>. pyrage, which binds to a Rust
|
|
5
|
+
implementation of age, creates and checks them. It ships no type stubs, so the
|
|
6
|
+
little of it that is used is given types here and nowhere else.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Protocol, cast
|
|
10
|
+
|
|
11
|
+
import pyrage # pyright: ignore[reportMissingTypeStubs]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _IdentityFactory(Protocol):
|
|
15
|
+
"""The parts of pyrage's x25519 Identity class that are used."""
|
|
16
|
+
|
|
17
|
+
def generate(self) -> object: ...
|
|
18
|
+
|
|
19
|
+
def from_str(self, value: str) -> object: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_IDENTITY = cast(
|
|
23
|
+
_IdentityFactory,
|
|
24
|
+
pyrage.x25519.Identity, # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_IDENTITY_ERROR = cast(
|
|
28
|
+
type[Exception],
|
|
29
|
+
pyrage.IdentityError, # pyright: ignore[reportAttributeAccessIssue]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def generate_age_key() -> str:
|
|
34
|
+
"""Return a new, random age secret key."""
|
|
35
|
+
return str(_IDENTITY.generate())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_age_key(value: str) -> str:
|
|
39
|
+
"""Return value, if it is an age secret key.
|
|
40
|
+
|
|
41
|
+
Raises ValueError if it is not, including a key with a mistyped character,
|
|
42
|
+
since the checksum is checked.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
_ = _IDENTITY.from_str(value)
|
|
46
|
+
except _IDENTITY_ERROR as exc:
|
|
47
|
+
raise ValueError("Not an age secret key") from exc
|
|
48
|
+
return value
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Config for claude-sync tasks.
|
|
2
|
+
# See https://github.com/tawanorg/claude-sync
|
|
3
|
+
|
|
4
|
+
# Profile in the AWS config, for an IAM user that can use the bucket.
|
|
5
|
+
aws_profile = "..."
|
|
6
|
+
|
|
7
|
+
# S3 bucket.
|
|
8
|
+
bucket = "..."
|
|
9
|
+
region = "..."
|
|
10
|
+
|
|
11
|
+
# What to sync: "sessions" or "full".
|
|
12
|
+
scope = "sessions"
|
|
13
|
+
|
|
14
|
+
# Encryption key.
|
|
15
|
+
# claude-sync.configure will create, so that all machines have the same key.
|
|
16
|
+
# age_key = "AGE-SECRET-KEY-1..."
|
|
@@ -98,6 +98,7 @@ def get_task_create(
|
|
|
98
98
|
) -> Task[Callable[..., None]]:
|
|
99
99
|
@task(
|
|
100
100
|
name="create",
|
|
101
|
+
auto_shortflags=False,
|
|
101
102
|
positional=[],
|
|
102
103
|
help={
|
|
103
104
|
"prompt": "Prompt for admin credentials.",
|
|
@@ -125,6 +126,7 @@ def get_task_delete(
|
|
|
125
126
|
) -> Task[Callable[..., None]]:
|
|
126
127
|
@task(
|
|
127
128
|
name="delete",
|
|
129
|
+
auto_shortflags=False,
|
|
128
130
|
positional=[],
|
|
129
131
|
help={
|
|
130
132
|
"prompt": "Prompt for admin credentials.",
|
|
@@ -159,6 +161,7 @@ def get_task_delete_key(
|
|
|
159
161
|
) -> Task[Callable[..., None]]:
|
|
160
162
|
@task(
|
|
161
163
|
name="delete-key",
|
|
164
|
+
auto_shortflags=False,
|
|
162
165
|
positional=[],
|
|
163
166
|
help={
|
|
164
167
|
"prompt": "Prompt for admin credentials.",
|
|
@@ -206,6 +209,7 @@ def get_task_list(
|
|
|
206
209
|
) -> Task[Callable[..., None]]:
|
|
207
210
|
@task(
|
|
208
211
|
name="list",
|
|
212
|
+
auto_shortflags=False,
|
|
209
213
|
help={
|
|
210
214
|
"prompt": "Prompt for admin credentials.",
|
|
211
215
|
},
|
|
@@ -235,6 +239,7 @@ def get_task_rotate_key(
|
|
|
235
239
|
) -> Task[Callable[..., None]]:
|
|
236
240
|
@task(
|
|
237
241
|
name="rotate-key",
|
|
242
|
+
auto_shortflags=False,
|
|
238
243
|
positional=[],
|
|
239
244
|
help={
|
|
240
245
|
"prompt": "Prompt for admin credentials.",
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Tasks for setting up and running claude-sync."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from invoke.collection import Collection
|
|
8
|
+
from invoke.context import Context
|
|
9
|
+
from invoke.tasks import Task, task
|
|
10
|
+
|
|
11
|
+
from fogies.tools.claude_sync import claude_sync, configure_claude_sync
|
|
12
|
+
from fogies.tools.command import CommandParams
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_task_configure(
|
|
16
|
+
*,
|
|
17
|
+
binary_cache_path: pathlib.Path,
|
|
18
|
+
claude_sync_config_path: pathlib.Path,
|
|
19
|
+
aws_config_path: pathlib.Path,
|
|
20
|
+
) -> Task[Callable[..., None]]:
|
|
21
|
+
@task(
|
|
22
|
+
name="configure",
|
|
23
|
+
auto_shortflags=False,
|
|
24
|
+
help={
|
|
25
|
+
"force": "Force overwrite based on current configuration.",
|
|
26
|
+
},
|
|
27
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
28
|
+
def task_configure(context: Context, force: bool = False) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Configure claude-sync on this machine.
|
|
31
|
+
"""
|
|
32
|
+
setup = configure_claude_sync(
|
|
33
|
+
claude_sync_config_path=claude_sync_config_path,
|
|
34
|
+
aws_config_path=aws_config_path,
|
|
35
|
+
force=force,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if setup.generated_age_key:
|
|
39
|
+
print("Generated encryption key.")
|
|
40
|
+
if setup.applied_to_claude_sync_files:
|
|
41
|
+
print("Applied configuration to claude-sync files.")
|
|
42
|
+
else:
|
|
43
|
+
print("Already configured.")
|
|
44
|
+
|
|
45
|
+
# Reads from the bucket and changes nothing, so this fails now, and
|
|
46
|
+
# clearly, if the credentials, bucket or key are wrong.
|
|
47
|
+
print("Checking the connection:")
|
|
48
|
+
with claude_sync(
|
|
49
|
+
binary_cache_path=binary_cache_path, raise_if_env_exists=False
|
|
50
|
+
) as cs:
|
|
51
|
+
_ = cs.pull(command_params=CommandParams(context=context), dry_run=True)
|
|
52
|
+
|
|
53
|
+
return cast(Task[Callable[..., None]], task_configure)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_task_pull(
|
|
57
|
+
*,
|
|
58
|
+
binary_cache_path: pathlib.Path,
|
|
59
|
+
default_dry_run: bool = False,
|
|
60
|
+
default_force: bool = False,
|
|
61
|
+
default_rebuild_history: bool = True,
|
|
62
|
+
) -> Task[Callable[..., None]]:
|
|
63
|
+
@task(
|
|
64
|
+
name="pull",
|
|
65
|
+
auto_shortflags=False,
|
|
66
|
+
help={
|
|
67
|
+
"dry_run": "Show what would be downloaded.",
|
|
68
|
+
"force": "Force overwrite of local files.",
|
|
69
|
+
"rebuild_history": "Rebuild history from session files.",
|
|
70
|
+
},
|
|
71
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
72
|
+
def task_pull(
|
|
73
|
+
context: Context,
|
|
74
|
+
dry_run: bool = default_dry_run,
|
|
75
|
+
force: bool = default_force,
|
|
76
|
+
rebuild_history: bool = default_rebuild_history,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Download remote changes.
|
|
80
|
+
"""
|
|
81
|
+
with claude_sync(
|
|
82
|
+
binary_cache_path=binary_cache_path, raise_if_env_exists=False
|
|
83
|
+
) as cs:
|
|
84
|
+
_ = cs.pull(
|
|
85
|
+
command_params=CommandParams(context=context),
|
|
86
|
+
dry_run=dry_run,
|
|
87
|
+
force=force,
|
|
88
|
+
rebuild_history=rebuild_history,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return cast(Task[Callable[..., None]], task_pull)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_task_push(*, binary_cache_path: pathlib.Path) -> Task[Callable[..., None]]:
|
|
95
|
+
@task(
|
|
96
|
+
name="push", auto_shortflags=False
|
|
97
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
98
|
+
def task_push(context: Context) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Upload local changes.
|
|
101
|
+
"""
|
|
102
|
+
with claude_sync(
|
|
103
|
+
binary_cache_path=binary_cache_path, raise_if_env_exists=False
|
|
104
|
+
) as cs:
|
|
105
|
+
_ = cs.push(command_params=CommandParams(context=context))
|
|
106
|
+
|
|
107
|
+
return cast(Task[Callable[..., None]], task_push)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_task_status(*, binary_cache_path: pathlib.Path) -> Task[Callable[..., None]]:
|
|
111
|
+
@task(
|
|
112
|
+
name="status", auto_shortflags=False
|
|
113
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
114
|
+
def task_status(context: Context) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Show local changes since prior synchronization.
|
|
117
|
+
"""
|
|
118
|
+
with claude_sync(
|
|
119
|
+
binary_cache_path=binary_cache_path, raise_if_env_exists=False
|
|
120
|
+
) as cs:
|
|
121
|
+
_ = cs.status(command_params=CommandParams(context=context))
|
|
122
|
+
|
|
123
|
+
return cast(Task[Callable[..., None]], task_status)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_collection(
|
|
127
|
+
*,
|
|
128
|
+
collection_name: str,
|
|
129
|
+
binary_cache_path: pathlib.Path,
|
|
130
|
+
claude_sync_config_path: pathlib.Path,
|
|
131
|
+
aws_config_path: pathlib.Path,
|
|
132
|
+
) -> Collection:
|
|
133
|
+
"""Get a collection of tasks for setting up and running claude-sync."""
|
|
134
|
+
collection = Collection(collection_name)
|
|
135
|
+
collection.add_task(
|
|
136
|
+
get_task_configure(
|
|
137
|
+
binary_cache_path=binary_cache_path,
|
|
138
|
+
claude_sync_config_path=claude_sync_config_path,
|
|
139
|
+
aws_config_path=aws_config_path,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
collection.add_task(get_task_pull(binary_cache_path=binary_cache_path))
|
|
143
|
+
collection.add_task(get_task_push(binary_cache_path=binary_cache_path))
|
|
144
|
+
collection.add_task(get_task_status(binary_cache_path=binary_cache_path))
|
|
145
|
+
return collection
|
|
@@ -26,7 +26,9 @@ def get_task_format(
|
|
|
26
26
|
if not (fmt_black or fmt_isort or fmt_terraform):
|
|
27
27
|
raise ValueError("At least one fmt flag must be set.")
|
|
28
28
|
|
|
29
|
-
@task(
|
|
29
|
+
@task(
|
|
30
|
+
name="format", auto_shortflags=False
|
|
31
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
30
32
|
def task_format(context: Context) -> None:
|
|
31
33
|
"""
|
|
32
34
|
Apply code formatting.
|
|
@@ -9,7 +9,9 @@ from invoke.tasks import Task, task
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def get_task_lint() -> Task[Callable[[Context], None]]:
|
|
12
|
-
@task(
|
|
12
|
+
@task(
|
|
13
|
+
name="lint", auto_shortflags=False
|
|
14
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
13
15
|
def task_lint(context: Context) -> None:
|
|
14
16
|
"""
|
|
15
17
|
Run linting.
|
|
@@ -14,7 +14,9 @@ from fogies.templates import ensure_from_template, poetry_toml_template_factory
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def get_task_build() -> Task[Callable[[Context], None]]:
|
|
17
|
-
@task(
|
|
17
|
+
@task(
|
|
18
|
+
name="build", auto_shortflags=False
|
|
19
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
18
20
|
def task_build(context: Context) -> None:
|
|
19
21
|
"""
|
|
20
22
|
Build package artifacts.
|
|
@@ -33,7 +35,9 @@ def get_task_build() -> Task[Callable[[Context], None]]:
|
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
def get_task_publish(*, secrets_poetry_path: Path) -> Task[Callable[[Context], None]]:
|
|
36
|
-
@task(
|
|
38
|
+
@task(
|
|
39
|
+
name="publish", auto_shortflags=False
|
|
40
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
37
41
|
def task_publish(context: Context) -> None:
|
|
38
42
|
"""
|
|
39
43
|
Publish package to PyPI.
|
|
@@ -21,6 +21,24 @@ from fogies.tools.terraform import (
|
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def validate_init_params(*, init: bool, init_params: InitParams) -> None:
|
|
25
|
+
"""Raise ValueError for task flags that cannot be given together.
|
|
26
|
+
|
|
27
|
+
The init options only apply when init runs, and Terraform does not allow
|
|
28
|
+
migrating state while also reconfiguring the backend.
|
|
29
|
+
"""
|
|
30
|
+
if init_params.migrate_state and not init:
|
|
31
|
+
raise ValueError("--init-migrate-state requires --init")
|
|
32
|
+
if init_params.reconfigure and not init:
|
|
33
|
+
raise ValueError("--init-reconfigure requires --init")
|
|
34
|
+
if init_params.upgrade and not init:
|
|
35
|
+
raise ValueError("--init-upgrade requires --init")
|
|
36
|
+
if init_params.migrate_state and init_params.reconfigure:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"--init-migrate-state and --init-reconfigure cannot be combined"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
24
42
|
def get_task_apply(
|
|
25
43
|
*,
|
|
26
44
|
binary_cache_path: pathlib.Path,
|
|
@@ -31,10 +49,11 @@ def get_task_apply(
|
|
|
31
49
|
tfbackend_factory: TfbackendFactory | None = None,
|
|
32
50
|
tfvars_factory: TfvarsFactory | None = None,
|
|
33
51
|
default_init: bool = True,
|
|
34
|
-
|
|
52
|
+
default_init_migrate_state: bool = False,
|
|
35
53
|
default_init_reconfigure: bool = False,
|
|
54
|
+
default_init_upgrade: bool = False,
|
|
36
55
|
default_apply_auto_approve: bool = False,
|
|
37
|
-
) -> Task[Callable[[Context, bool, bool, bool, bool], None]]:
|
|
56
|
+
) -> Task[Callable[[Context, bool, bool, bool, bool, bool], None]]:
|
|
38
57
|
if backend_config is not None and tfbackend_factory is None:
|
|
39
58
|
raise ValueError("tfbackend_factory is required when backend_config is set")
|
|
40
59
|
if (backend_config is not None or tfbackend_factory is not None) and (
|
|
@@ -50,34 +69,34 @@ def get_task_apply(
|
|
|
50
69
|
|
|
51
70
|
@task(
|
|
52
71
|
name="apply",
|
|
72
|
+
auto_shortflags=False,
|
|
53
73
|
help={
|
|
54
74
|
"init": "Include terraform init. Skip with --no-init.",
|
|
55
|
-
"
|
|
75
|
+
"init_migrate_state": "During init, migrate state to a changed backend.",
|
|
56
76
|
"init_reconfigure": "During init, reconfigure the backend.",
|
|
77
|
+
"init_upgrade": "During init, upgrade providers.",
|
|
57
78
|
"apply_auto_approve": "Skip interactive confirmation.",
|
|
58
79
|
},
|
|
59
80
|
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
60
81
|
def task_apply(
|
|
61
82
|
context: Context,
|
|
62
83
|
init: bool = default_init,
|
|
63
|
-
|
|
84
|
+
init_migrate_state: bool = default_init_migrate_state,
|
|
64
85
|
init_reconfigure: bool = default_init_reconfigure,
|
|
86
|
+
init_upgrade: bool = default_init_upgrade,
|
|
65
87
|
apply_auto_approve: bool = default_apply_auto_approve,
|
|
66
88
|
) -> None:
|
|
67
89
|
"""
|
|
68
90
|
Apply a Terraform configuration.
|
|
69
91
|
"""
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
92
|
+
init_params = InitParams(
|
|
93
|
+
migrate_state=init_migrate_state,
|
|
94
|
+
reconfigure=init_reconfigure,
|
|
95
|
+
upgrade=init_upgrade,
|
|
96
|
+
)
|
|
97
|
+
validate_init_params(init=init, init_params=init_params)
|
|
74
98
|
|
|
75
99
|
command_params = CommandParams(context=context)
|
|
76
|
-
init_params = (
|
|
77
|
-
InitParams(upgrade=init_upgrade, reconfigure=init_reconfigure)
|
|
78
|
-
if init
|
|
79
|
-
else None
|
|
80
|
-
)
|
|
81
100
|
|
|
82
101
|
with ExitStack() as stack:
|
|
83
102
|
aws_environ = (
|
|
@@ -113,7 +132,9 @@ def get_task_apply(
|
|
|
113
132
|
)
|
|
114
133
|
)
|
|
115
134
|
|
|
116
|
-
return cast(
|
|
135
|
+
return cast(
|
|
136
|
+
Task[Callable[[Context, bool, bool, bool, bool, bool], None]], task_apply
|
|
137
|
+
)
|
|
117
138
|
|
|
118
139
|
|
|
119
140
|
def get_task_destroy(
|
|
@@ -126,10 +147,11 @@ def get_task_destroy(
|
|
|
126
147
|
tfbackend_factory: TfbackendFactory | None = None,
|
|
127
148
|
tfvars_factory: TfvarsFactory | None = None,
|
|
128
149
|
default_init: bool = True,
|
|
129
|
-
|
|
150
|
+
default_init_migrate_state: bool = False,
|
|
130
151
|
default_init_reconfigure: bool = False,
|
|
152
|
+
default_init_upgrade: bool = False,
|
|
131
153
|
default_destroy_auto_approve: bool = False,
|
|
132
|
-
) -> Task[Callable[[Context, bool, bool, bool, bool], None]]:
|
|
154
|
+
) -> Task[Callable[[Context, bool, bool, bool, bool, bool], None]]:
|
|
133
155
|
if backend_config is not None and tfbackend_factory is None:
|
|
134
156
|
raise ValueError("tfbackend_factory is required when backend_config is set")
|
|
135
157
|
if (backend_config is not None or tfbackend_factory is not None) and (
|
|
@@ -145,34 +167,34 @@ def get_task_destroy(
|
|
|
145
167
|
|
|
146
168
|
@task(
|
|
147
169
|
name="destroy",
|
|
170
|
+
auto_shortflags=False,
|
|
148
171
|
help={
|
|
149
172
|
"init": "Include terraform init. Skip with --no-init.",
|
|
150
|
-
"
|
|
173
|
+
"init_migrate_state": "During init, migrate state to a changed backend.",
|
|
151
174
|
"init_reconfigure": "During init, reconfigure the backend.",
|
|
175
|
+
"init_upgrade": "During init, upgrade providers.",
|
|
152
176
|
"destroy_auto_approve": "Skip interactive confirmation.",
|
|
153
177
|
},
|
|
154
178
|
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
155
179
|
def task_destroy(
|
|
156
180
|
context: Context,
|
|
157
181
|
init: bool = default_init,
|
|
158
|
-
|
|
182
|
+
init_migrate_state: bool = default_init_migrate_state,
|
|
159
183
|
init_reconfigure: bool = default_init_reconfigure,
|
|
184
|
+
init_upgrade: bool = default_init_upgrade,
|
|
160
185
|
destroy_auto_approve: bool = default_destroy_auto_approve,
|
|
161
186
|
) -> None:
|
|
162
187
|
"""
|
|
163
188
|
Destroy a Terraform configuration.
|
|
164
189
|
"""
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
190
|
+
init_params = InitParams(
|
|
191
|
+
migrate_state=init_migrate_state,
|
|
192
|
+
reconfigure=init_reconfigure,
|
|
193
|
+
upgrade=init_upgrade,
|
|
194
|
+
)
|
|
195
|
+
validate_init_params(init=init, init_params=init_params)
|
|
169
196
|
|
|
170
197
|
command_params = CommandParams(context=context)
|
|
171
|
-
init_params = (
|
|
172
|
-
InitParams(upgrade=init_upgrade, reconfigure=init_reconfigure)
|
|
173
|
-
if init
|
|
174
|
-
else None
|
|
175
|
-
)
|
|
176
198
|
|
|
177
199
|
with ExitStack() as stack:
|
|
178
200
|
aws_environ = (
|
|
@@ -207,7 +229,9 @@ def get_task_destroy(
|
|
|
207
229
|
)
|
|
208
230
|
)
|
|
209
231
|
|
|
210
|
-
return cast(
|
|
232
|
+
return cast(
|
|
233
|
+
Task[Callable[[Context, bool, bool, bool, bool, bool], None]], task_destroy
|
|
234
|
+
)
|
|
211
235
|
|
|
212
236
|
|
|
213
237
|
def get_collection(
|
|
@@ -9,6 +9,7 @@ from invoke.collection import Collection
|
|
|
9
9
|
from invoke.context import Context
|
|
10
10
|
from invoke.tasks import Task, task
|
|
11
11
|
|
|
12
|
+
from fogies.tasks.terraform import validate_init_params
|
|
12
13
|
from fogies.terraform.backend import BackendOutput
|
|
13
14
|
from fogies.tools.aws_environ import AwsEnvironFactory
|
|
14
15
|
from fogies.tools.command import CommandParams
|
|
@@ -33,40 +34,34 @@ def get_task_backend_apply(
|
|
|
33
34
|
output_model: type[TerraformOutputModel],
|
|
34
35
|
output_model_get_backend: Callable[[TerraformOutputModel], BackendOutput],
|
|
35
36
|
default_init: bool = True,
|
|
36
|
-
default_init_upgrade: bool = False,
|
|
37
37
|
default_init_reconfigure: bool = False,
|
|
38
|
+
default_init_upgrade: bool = False,
|
|
38
39
|
default_apply_auto_approve: bool = False,
|
|
39
40
|
) -> Task[Callable[[Context, bool, bool, bool, bool], None]]:
|
|
40
41
|
@task(
|
|
41
42
|
name="apply",
|
|
43
|
+
auto_shortflags=False,
|
|
42
44
|
help={
|
|
43
45
|
"init": "Include terraform init. Skip with --no-init.",
|
|
44
|
-
"init_upgrade": "During init, upgrade providers.",
|
|
45
46
|
"init_reconfigure": "During init, reconfigure the backend.",
|
|
47
|
+
"init_upgrade": "During init, upgrade providers.",
|
|
46
48
|
"apply_auto_approve": "Skip interactive confirmation.",
|
|
47
49
|
},
|
|
48
50
|
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
49
51
|
def task_apply(
|
|
50
52
|
context: Context,
|
|
51
53
|
init: bool = default_init,
|
|
52
|
-
init_upgrade: bool = default_init_upgrade,
|
|
53
54
|
init_reconfigure: bool = default_init_reconfigure,
|
|
55
|
+
init_upgrade: bool = default_init_upgrade,
|
|
54
56
|
apply_auto_approve: bool = default_apply_auto_approve,
|
|
55
57
|
) -> None:
|
|
56
58
|
"""
|
|
57
59
|
Apply a Terraform backend configuration, and record it as applied.
|
|
58
60
|
"""
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if init_reconfigure and not init:
|
|
62
|
-
raise ValueError("--init-reconfigure requires --init")
|
|
61
|
+
init_params = InitParams(reconfigure=init_reconfigure, upgrade=init_upgrade)
|
|
62
|
+
validate_init_params(init=init, init_params=init_params)
|
|
63
63
|
|
|
64
64
|
command_params = CommandParams(context=context)
|
|
65
|
-
init_params = (
|
|
66
|
-
InitParams(upgrade=init_upgrade, reconfigure=init_reconfigure)
|
|
67
|
-
if init
|
|
68
|
-
else None
|
|
69
|
-
)
|
|
70
65
|
|
|
71
66
|
with ExitStack() as stack:
|
|
72
67
|
if aws_environ_factory is not None:
|
|
@@ -108,24 +103,25 @@ def get_task_backend_destroy(
|
|
|
108
103
|
output_model: type[TerraformOutputModel],
|
|
109
104
|
output_model_get_backend: Callable[[TerraformOutputModel], BackendOutput],
|
|
110
105
|
default_init: bool = True,
|
|
111
|
-
default_init_upgrade: bool = False,
|
|
112
106
|
default_init_reconfigure: bool = False,
|
|
107
|
+
default_init_upgrade: bool = False,
|
|
113
108
|
default_destroy_auto_approve: bool = False,
|
|
114
109
|
) -> Task[Callable[[Context, bool, bool, bool, bool], None]]:
|
|
115
110
|
@task(
|
|
116
111
|
name="destroy",
|
|
112
|
+
auto_shortflags=False,
|
|
117
113
|
help={
|
|
118
114
|
"init": "Include terraform init. Skip with --no-init.",
|
|
119
|
-
"init_upgrade": "During init, upgrade providers.",
|
|
120
115
|
"init_reconfigure": "During init, reconfigure the backend.",
|
|
116
|
+
"init_upgrade": "During init, upgrade providers.",
|
|
121
117
|
"destroy_auto_approve": "Skip interactive confirmation.",
|
|
122
118
|
},
|
|
123
119
|
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
124
120
|
def task_destroy(
|
|
125
121
|
context: Context,
|
|
126
122
|
init: bool = default_init,
|
|
127
|
-
init_upgrade: bool = default_init_upgrade,
|
|
128
123
|
init_reconfigure: bool = default_init_reconfigure,
|
|
124
|
+
init_upgrade: bool = default_init_upgrade,
|
|
129
125
|
destroy_auto_approve: bool = default_destroy_auto_approve,
|
|
130
126
|
) -> None:
|
|
131
127
|
"""
|
|
@@ -136,17 +132,10 @@ def get_task_backend_destroy(
|
|
|
136
132
|
objects before destroying it. Refuses (raises) if any declared state
|
|
137
133
|
still has resources, regardless of how this task is invoked.
|
|
138
134
|
"""
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if init_reconfigure and not init:
|
|
142
|
-
raise ValueError("--init-reconfigure requires --init")
|
|
135
|
+
init_params = InitParams(reconfigure=init_reconfigure, upgrade=init_upgrade)
|
|
136
|
+
validate_init_params(init=init, init_params=init_params)
|
|
143
137
|
|
|
144
138
|
command_params = CommandParams(context=context)
|
|
145
|
-
init_params = (
|
|
146
|
-
InitParams(upgrade=init_upgrade, reconfigure=init_reconfigure)
|
|
147
|
-
if init
|
|
148
|
-
else None
|
|
149
|
-
)
|
|
150
139
|
|
|
151
140
|
with ExitStack() as stack:
|
|
152
141
|
if aws_environ_factory is not None:
|
|
@@ -14,7 +14,9 @@ from invoke.tasks import Task, task
|
|
|
14
14
|
def get_task_test(
|
|
15
15
|
*, tests_path: pathlib.Path | None = None
|
|
16
16
|
) -> Task[Callable[[Context], None]]:
|
|
17
|
-
@task(
|
|
17
|
+
@task(
|
|
18
|
+
name="test", auto_shortflags=False
|
|
19
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
18
20
|
def task_test(context: Context) -> None:
|
|
19
21
|
"""
|
|
20
22
|
Run tests.
|
|
@@ -56,5 +56,9 @@ def backend_status_toml_template_factory() -> str:
|
|
|
56
56
|
return _read_template_from_resources("backend_status.toml.template")
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
def claude_sync_toml_template_factory() -> str:
|
|
60
|
+
return _read_template_from_resources("claude_sync.toml.template")
|
|
61
|
+
|
|
62
|
+
|
|
59
63
|
def poetry_toml_template_factory() -> str:
|
|
60
64
|
return _read_template_from_resources("poetry.toml.template")
|
|
@@ -80,7 +80,7 @@ class BackendOutput(BaseModel):
|
|
|
80
80
|
region: str
|
|
81
81
|
state_keys: dict[str, str]
|
|
82
82
|
|
|
83
|
-
def
|
|
83
|
+
def backend_config(self, *, state_name: str) -> BackendConfig:
|
|
84
84
|
"""Return connection config for one of this backend's declared states.
|
|
85
85
|
|
|
86
86
|
Raises ValueError if state_name is not declared as part of the backend.
|
|
@@ -101,7 +101,7 @@ class BackendOutput(BaseModel):
|
|
|
101
101
|
)
|
|
102
102
|
|
|
103
103
|
def __getitem__(self, state_name: str) -> BackendConfig:
|
|
104
|
-
return self.
|
|
104
|
+
return self.backend_config(state_name=state_name)
|
|
105
105
|
|
|
106
106
|
|
|
107
107
|
def backend_delete_state_objects(*, output: BackendOutput) -> None:
|
|
@@ -137,16 +137,18 @@ def backend_delete_state_objects(*, output: BackendOutput) -> None:
|
|
|
137
137
|
)
|
|
138
138
|
|
|
139
139
|
|
|
140
|
-
def backend_state_resources(
|
|
141
|
-
|
|
140
|
+
def backend_state_resources(
|
|
141
|
+
*, backend_config: BackendConfig, account_id: str
|
|
142
|
+
) -> list[object]:
|
|
143
|
+
"""Return backend_config's resources from the backend bucket.
|
|
142
144
|
|
|
143
145
|
Returns an empty list if the state's object is absent (never applied) or
|
|
144
146
|
has no resources.
|
|
145
147
|
"""
|
|
146
148
|
return _state_resources(
|
|
147
|
-
bucket_name=
|
|
148
|
-
region=
|
|
149
|
-
key=
|
|
149
|
+
bucket_name=backend_config.bucket_name(account_id=account_id),
|
|
150
|
+
region=backend_config.region,
|
|
151
|
+
key=backend_config.key,
|
|
150
152
|
)
|
|
151
153
|
|
|
152
154
|
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Tool wrapping claude-sync, built with a portable Go toolchain.
|
|
2
|
+
|
|
3
|
+
See <https://github.com/tawanorg/claude-sync>. claude-sync ships no Windows
|
|
4
|
+
release binary, so it's built from source here rather than downloaded
|
|
5
|
+
pre-built, unlike fogies.tools.terraform. Only push/pull/status are wrapped.
|
|
6
|
+
|
|
7
|
+
claude-sync's own setup wizard (`claude-sync init`) can't run unattended: for
|
|
8
|
+
S3 it prompts for the credentials, region and bucket even when they are given
|
|
9
|
+
as flags, and for the passphrase its encryption key can be derived from. So a
|
|
10
|
+
machine is set up by writing the files claude-sync reads, from one
|
|
11
|
+
gitignored config that is synced between machines like the other secrets. That
|
|
12
|
+
config holds the encryption key itself, not a passphrase; see fogies.age_key.
|
|
13
|
+
The credentials are not in it, but in a profile of the AWS config.
|
|
14
|
+
|
|
15
|
+
The files claude-sync reads are managed only from that config, not with
|
|
16
|
+
claude-sync's own commands. They are compared exactly, so changing them any
|
|
17
|
+
other way makes them differ from it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import dataclasses
|
|
21
|
+
import json
|
|
22
|
+
import pathlib
|
|
23
|
+
import tomllib
|
|
24
|
+
from collections.abc import Generator
|
|
25
|
+
from contextlib import contextmanager
|
|
26
|
+
from typing import Literal
|
|
27
|
+
|
|
28
|
+
import tomlkit
|
|
29
|
+
from invoke.runners import Result
|
|
30
|
+
from pydantic import BaseModel, field_validator
|
|
31
|
+
|
|
32
|
+
from fogies.age_key import generate_age_key, validate_age_key
|
|
33
|
+
from fogies.templates import claude_sync_toml_template_factory, ensure_from_template
|
|
34
|
+
from fogies.tools.aws_environ import AwsProfile, load_aws_profile_from_config
|
|
35
|
+
from fogies.tools.command import CommandParams, command_run
|
|
36
|
+
from fogies.tools.go import go
|
|
37
|
+
|
|
38
|
+
# Most recent first; _KNOWN_VERSIONS[0] is the default.
|
|
39
|
+
_KNOWN_VERSIONS = [
|
|
40
|
+
"1.17.1",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
_DEFAULT_VERSION = _KNOWN_VERSIONS[0]
|
|
44
|
+
|
|
45
|
+
_MODULE_TEMPLATE = "github.com/tawanorg/claude-sync/cmd/claude-sync@v{version}"
|
|
46
|
+
|
|
47
|
+
# The claude-sync files, in the home directory.
|
|
48
|
+
_DIRECTORY_NAME = ".claude-sync"
|
|
49
|
+
_YAML_FILE_NAME = "config.yaml"
|
|
50
|
+
_KEY_FILE_NAME = "age-key.txt"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def claude_sync_files_directory() -> pathlib.Path:
|
|
54
|
+
"""Where the claude-sync files are: its config.yaml and encryption key."""
|
|
55
|
+
return pathlib.Path.home() / _DIRECTORY_NAME
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ClaudeSyncConfig(BaseModel):
|
|
59
|
+
"""Contents of the claude-sync config."""
|
|
60
|
+
|
|
61
|
+
# A profile in the AWS config, with the credentials claude-sync signs in with.
|
|
62
|
+
aws_profile: str
|
|
63
|
+
bucket: str
|
|
64
|
+
region: str
|
|
65
|
+
scope: Literal["full", "sessions"]
|
|
66
|
+
# Absent until created by configure.
|
|
67
|
+
age_key: str | None = None
|
|
68
|
+
|
|
69
|
+
@field_validator("age_key")
|
|
70
|
+
@classmethod
|
|
71
|
+
def _age_key_is_an_age_key(cls, value: str | None) -> str | None:
|
|
72
|
+
return None if value is None else validate_age_key(value)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_claude_sync_config(*, path: pathlib.Path) -> ClaudeSyncConfig:
|
|
76
|
+
"""Return the config in path.
|
|
77
|
+
|
|
78
|
+
If it doesn't exist yet, it is created from a template and FileNotFoundError
|
|
79
|
+
is raised, so that it is filled in before it is used.
|
|
80
|
+
"""
|
|
81
|
+
ensure_from_template(path=path, template_factory=claude_sync_toml_template_factory)
|
|
82
|
+
with path.open("rb") as f:
|
|
83
|
+
return ClaudeSyncConfig.model_validate(tomllib.load(f))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def update_claude_sync_config(*, path: pathlib.Path, age_key: str) -> None:
|
|
87
|
+
"""Update the config with age_key, keeping its comments and layout."""
|
|
88
|
+
with path.open("r", encoding="utf-8") as f:
|
|
89
|
+
doc = tomlkit.parse(f.read())
|
|
90
|
+
doc["age_key"] = age_key
|
|
91
|
+
with path.open("w", encoding="utf-8", newline="\n") as f:
|
|
92
|
+
_ = f.write(tomlkit.dumps(doc))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def render_claude_sync_yaml(config: ClaudeSyncConfig, aws_profile: AwsProfile) -> str:
|
|
96
|
+
"""Return the contents of claude-sync's config.yaml for config and aws_profile.
|
|
97
|
+
|
|
98
|
+
As `claude-sync init` writes for S3, except that the scope is always
|
|
99
|
+
written, not left out when it's the default. Values are written as JSON
|
|
100
|
+
strings, which are also valid YAML strings, so that none needs escaping.
|
|
101
|
+
"""
|
|
102
|
+
lines = [
|
|
103
|
+
"storage:",
|
|
104
|
+
" provider: s3",
|
|
105
|
+
" bucket: " + json.dumps(config.bucket),
|
|
106
|
+
" access_key_id: " + json.dumps(aws_profile.aws_access_key_id),
|
|
107
|
+
" secret_access_key: " + json.dumps(aws_profile.aws_secret_access_key),
|
|
108
|
+
" region: " + json.dumps(config.region),
|
|
109
|
+
"encryption_key_path: "
|
|
110
|
+
+ json.dumps("~/{}/{}".format(_DIRECTORY_NAME, _KEY_FILE_NAME)),
|
|
111
|
+
"scope: " + config.scope,
|
|
112
|
+
]
|
|
113
|
+
return "\n".join(lines) + "\n"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclasses.dataclass(frozen=True)
|
|
117
|
+
class ClaudeSyncConfigureResult:
|
|
118
|
+
"""What configure_claude_sync() did."""
|
|
119
|
+
|
|
120
|
+
generated_age_key: bool
|
|
121
|
+
applied_to_claude_sync_files: bool
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def configure_claude_sync(
|
|
125
|
+
*, claude_sync_config_path: pathlib.Path, aws_config_path: pathlib.Path, force: bool
|
|
126
|
+
) -> ClaudeSyncConfigureResult:
|
|
127
|
+
"""Apply the config at claude_sync_config_path to the claude-sync files.
|
|
128
|
+
|
|
129
|
+
The credentials are those of the config's profile in the AWS config at
|
|
130
|
+
aws_config_path.
|
|
131
|
+
|
|
132
|
+
The config decides what claude-sync's encryption key and config.yaml
|
|
133
|
+
should be, in claude_sync_files_directory(). If it has no key yet, a new one
|
|
134
|
+
is generated and saved in it, so that other machines use it too.
|
|
135
|
+
|
|
136
|
+
A claude-sync file that is missing is written, and one that already
|
|
137
|
+
matches is left alone. One that differs is an error, and nothing is
|
|
138
|
+
written, unless force is set, which replaces it.
|
|
139
|
+
"""
|
|
140
|
+
# Where the claude-sync files are.
|
|
141
|
+
directory = claude_sync_files_directory()
|
|
142
|
+
key_path = directory / _KEY_FILE_NAME
|
|
143
|
+
|
|
144
|
+
# The key in the config, or a new one if it has none.
|
|
145
|
+
config = load_claude_sync_config(path=claude_sync_config_path)
|
|
146
|
+
aws_profile = load_aws_profile_from_config(
|
|
147
|
+
config_path=aws_config_path, profile_name=config.aws_profile
|
|
148
|
+
)
|
|
149
|
+
generated_age_key = config.age_key is None
|
|
150
|
+
age_key = generate_age_key() if config.age_key is None else config.age_key
|
|
151
|
+
|
|
152
|
+
# What the claude-sync files should contain.
|
|
153
|
+
expected_content = {
|
|
154
|
+
key_path: age_key + "\n",
|
|
155
|
+
directory / _YAML_FILE_NAME: render_claude_sync_yaml(config, aws_profile),
|
|
156
|
+
}
|
|
157
|
+
# Which of them are missing or differ.
|
|
158
|
+
paths_to_write = [
|
|
159
|
+
path
|
|
160
|
+
for path, text in expected_content.items()
|
|
161
|
+
if not path.exists() or path.read_text(encoding="utf-8") != text
|
|
162
|
+
]
|
|
163
|
+
# Which of those exist but differ.
|
|
164
|
+
paths_that_differ = [path for path in paths_to_write if path.exists()]
|
|
165
|
+
|
|
166
|
+
# Refuse to replace what differs, unless forced.
|
|
167
|
+
if paths_that_differ and not force:
|
|
168
|
+
raise ValueError("Config does not match existing claude-sync files.")
|
|
169
|
+
|
|
170
|
+
# Save a generated key in the config, for other machines.
|
|
171
|
+
if generated_age_key:
|
|
172
|
+
update_claude_sync_config(path=claude_sync_config_path, age_key=age_key)
|
|
173
|
+
|
|
174
|
+
# Write the claude-sync files.
|
|
175
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
for path in paths_to_write:
|
|
177
|
+
_ = path.write_text(expected_content[path], encoding="utf-8", newline="\n")
|
|
178
|
+
|
|
179
|
+
return ClaudeSyncConfigureResult(
|
|
180
|
+
generated_age_key=generated_age_key,
|
|
181
|
+
applied_to_claude_sync_files=bool(paths_to_write),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _ClaudeSync:
|
|
186
|
+
"""Represents a claude-sync binary."""
|
|
187
|
+
|
|
188
|
+
_path: pathlib.Path
|
|
189
|
+
|
|
190
|
+
def __init__(self, *, path: pathlib.Path) -> None:
|
|
191
|
+
self._path = path
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def binary_path(self) -> pathlib.Path:
|
|
195
|
+
"""The path to the claude-sync executable."""
|
|
196
|
+
return self._path
|
|
197
|
+
|
|
198
|
+
def push(
|
|
199
|
+
self,
|
|
200
|
+
*,
|
|
201
|
+
command_params: CommandParams,
|
|
202
|
+
include_mcp: bool = False,
|
|
203
|
+
quiet: bool = False,
|
|
204
|
+
) -> Result:
|
|
205
|
+
"""Run `claude-sync push`, uploading local changes to cloud storage."""
|
|
206
|
+
push_args = ["push"]
|
|
207
|
+
if include_mcp:
|
|
208
|
+
push_args.append("--include-mcp")
|
|
209
|
+
if quiet:
|
|
210
|
+
push_args.append("-q")
|
|
211
|
+
return command_run(
|
|
212
|
+
command=self.binary_path,
|
|
213
|
+
command_params=command_params,
|
|
214
|
+
args=push_args,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def pull(
|
|
218
|
+
self,
|
|
219
|
+
*,
|
|
220
|
+
command_params: CommandParams,
|
|
221
|
+
dry_run: bool = False,
|
|
222
|
+
force: bool = False,
|
|
223
|
+
include_mcp: bool = False,
|
|
224
|
+
quiet: bool = False,
|
|
225
|
+
rebuild_history: bool = False,
|
|
226
|
+
) -> Result:
|
|
227
|
+
"""Run `claude-sync pull`, downloading remote changes."""
|
|
228
|
+
pull_args = ["pull"]
|
|
229
|
+
if dry_run:
|
|
230
|
+
pull_args.append("--dry-run")
|
|
231
|
+
if force:
|
|
232
|
+
pull_args.append("--force")
|
|
233
|
+
if include_mcp:
|
|
234
|
+
pull_args.append("--include-mcp")
|
|
235
|
+
if quiet:
|
|
236
|
+
pull_args.append("-q")
|
|
237
|
+
if rebuild_history:
|
|
238
|
+
pull_args.append("--rebuild-history")
|
|
239
|
+
return command_run(
|
|
240
|
+
command=self.binary_path,
|
|
241
|
+
command_params=command_params,
|
|
242
|
+
args=pull_args,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def status(self, *, command_params: CommandParams, quiet: bool = False) -> Result:
|
|
246
|
+
"""Run `claude-sync status`, showing pending local changes."""
|
|
247
|
+
status_args = ["status"]
|
|
248
|
+
if quiet:
|
|
249
|
+
status_args.append("-q")
|
|
250
|
+
return command_run(
|
|
251
|
+
command=self.binary_path,
|
|
252
|
+
command_params=command_params,
|
|
253
|
+
args=status_args,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@contextmanager
|
|
258
|
+
def claude_sync(
|
|
259
|
+
*,
|
|
260
|
+
version: str | None = None,
|
|
261
|
+
binary_cache_path: pathlib.Path,
|
|
262
|
+
raise_if_env_exists: bool,
|
|
263
|
+
) -> Generator[_ClaudeSync]:
|
|
264
|
+
"""Build claude-sync with a portable Go toolchain (if not cached) and yield a handle.
|
|
265
|
+
|
|
266
|
+
*version* when None uses the bundled default claude-sync version. Built
|
|
267
|
+
into a version-specific subdirectory of binary_cache_path (matching
|
|
268
|
+
fogies.tools.go's own SDK caching), so bumping the pinned version
|
|
269
|
+
triggers a fresh build rather than silently reusing a stale binary.
|
|
270
|
+
|
|
271
|
+
raise_if_env_exists has no default; see fogies.tools.environ.environ()
|
|
272
|
+
for why. Passed through to go().install()'s own GOBIN isolation.
|
|
273
|
+
"""
|
|
274
|
+
if version is None:
|
|
275
|
+
version = _DEFAULT_VERSION
|
|
276
|
+
|
|
277
|
+
if version not in _KNOWN_VERSIONS:
|
|
278
|
+
known = ", ".join(_KNOWN_VERSIONS)
|
|
279
|
+
raise ValueError(
|
|
280
|
+
"Unknown claude-sync version '{}'; known versions: {}".format(
|
|
281
|
+
version, known
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
gobin_path = binary_cache_path / "claude_sync_{}".format(version.replace(".", "_"))
|
|
286
|
+
exe_path = gobin_path / "claude-sync.exe"
|
|
287
|
+
|
|
288
|
+
if not exe_path.exists():
|
|
289
|
+
with go(binary_cache_path=binary_cache_path) as go_tool:
|
|
290
|
+
_ = go_tool.install(
|
|
291
|
+
command_params=CommandParams(in_stream=False),
|
|
292
|
+
module=_MODULE_TEMPLATE.format(version=version),
|
|
293
|
+
gobin_path=gobin_path,
|
|
294
|
+
raise_if_env_exists=raise_if_env_exists,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if not exe_path.exists():
|
|
298
|
+
raise RuntimeError(
|
|
299
|
+
"claude-sync executable not found in '{}'".format(gobin_path)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
yield _ClaudeSync(path=exe_path)
|
|
@@ -477,7 +477,8 @@ def terraform(
|
|
|
477
477
|
backend_status.states[backend_config.state_name] = BackendStatusEntry(
|
|
478
478
|
applied=bool(
|
|
479
479
|
backend_state_resources(
|
|
480
|
-
|
|
480
|
+
backend_config=backend_config,
|
|
481
|
+
account_id=aws_environ.account_id,
|
|
481
482
|
)
|
|
482
483
|
)
|
|
483
484
|
)
|
|
@@ -501,7 +502,8 @@ def terraform(
|
|
|
501
502
|
backend_status.states[backend_config.state_name] = BackendStatusEntry(
|
|
502
503
|
applied=bool(
|
|
503
504
|
backend_state_resources(
|
|
504
|
-
|
|
505
|
+
backend_config=backend_config,
|
|
506
|
+
account_id=aws_environ.account_id,
|
|
505
507
|
)
|
|
506
508
|
)
|
|
507
509
|
)
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
"""Tool wrapping claude-sync, built with a portable Go toolchain.
|
|
2
|
-
|
|
3
|
-
See <https://github.com/tawanorg/claude-sync>. claude-sync ships no Windows
|
|
4
|
-
release binary, so it's built from source here rather than downloaded
|
|
5
|
-
pre-built, unlike fogies.tools.terraform. Only push/pull/status are wrapped;
|
|
6
|
-
claude-sync's own storage setup (S3/R2/etc. credentials, bucket, encryption
|
|
7
|
-
passphrase) is configured through `claude-sync init`'s interactive wizard,
|
|
8
|
-
which is out of scope here.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import pathlib
|
|
12
|
-
from collections.abc import Generator
|
|
13
|
-
from contextlib import contextmanager
|
|
14
|
-
|
|
15
|
-
from invoke.runners import Result
|
|
16
|
-
|
|
17
|
-
from fogies.tools.command import CommandParams, command_run
|
|
18
|
-
from fogies.tools.go import go
|
|
19
|
-
|
|
20
|
-
# Most recent first; _KNOWN_VERSIONS[0] is the default.
|
|
21
|
-
_KNOWN_VERSIONS = [
|
|
22
|
-
"1.17.1",
|
|
23
|
-
]
|
|
24
|
-
|
|
25
|
-
_DEFAULT_VERSION = _KNOWN_VERSIONS[0]
|
|
26
|
-
|
|
27
|
-
_MODULE_TEMPLATE = "github.com/tawanorg/claude-sync/cmd/claude-sync@v{version}"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class _ClaudeSync:
|
|
31
|
-
"""Represents a claude-sync binary."""
|
|
32
|
-
|
|
33
|
-
_path: pathlib.Path
|
|
34
|
-
|
|
35
|
-
def __init__(self, *, path: pathlib.Path) -> None:
|
|
36
|
-
self._path = path
|
|
37
|
-
|
|
38
|
-
@property
|
|
39
|
-
def binary_path(self) -> pathlib.Path:
|
|
40
|
-
"""The path to the claude-sync executable."""
|
|
41
|
-
return self._path
|
|
42
|
-
|
|
43
|
-
def push(self, *, command_params: CommandParams, quiet: bool = False) -> Result:
|
|
44
|
-
"""Run `claude-sync push`, uploading local changes to cloud storage."""
|
|
45
|
-
push_args = ["push"]
|
|
46
|
-
if quiet:
|
|
47
|
-
push_args.append("-q")
|
|
48
|
-
return command_run(
|
|
49
|
-
command=self.binary_path,
|
|
50
|
-
command_params=command_params,
|
|
51
|
-
args=push_args,
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
def pull(
|
|
55
|
-
self,
|
|
56
|
-
*,
|
|
57
|
-
command_params: CommandParams,
|
|
58
|
-
dry_run: bool = False,
|
|
59
|
-
force: bool = False,
|
|
60
|
-
quiet: bool = False,
|
|
61
|
-
) -> Result:
|
|
62
|
-
"""Run `claude-sync pull`, downloading remote changes."""
|
|
63
|
-
pull_args = ["pull"]
|
|
64
|
-
if dry_run:
|
|
65
|
-
pull_args.append("--dry-run")
|
|
66
|
-
if force:
|
|
67
|
-
pull_args.append("--force")
|
|
68
|
-
if quiet:
|
|
69
|
-
pull_args.append("-q")
|
|
70
|
-
return command_run(
|
|
71
|
-
command=self.binary_path,
|
|
72
|
-
command_params=command_params,
|
|
73
|
-
args=pull_args,
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
def status(self, *, command_params: CommandParams) -> Result:
|
|
77
|
-
"""Run `claude-sync status`, showing pending local changes."""
|
|
78
|
-
return command_run(
|
|
79
|
-
command=self.binary_path,
|
|
80
|
-
command_params=command_params,
|
|
81
|
-
args=["status"],
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
@contextmanager
|
|
86
|
-
def claude_sync(
|
|
87
|
-
*,
|
|
88
|
-
version: str | None = None,
|
|
89
|
-
binary_cache_path: pathlib.Path,
|
|
90
|
-
raise_if_env_exists: bool,
|
|
91
|
-
) -> Generator[_ClaudeSync]:
|
|
92
|
-
"""Build claude-sync with a portable Go toolchain (if not cached) and yield a handle.
|
|
93
|
-
|
|
94
|
-
*version* when None uses the bundled default claude-sync version. Built
|
|
95
|
-
into a version-specific subdirectory of binary_cache_path (matching
|
|
96
|
-
fogies.tools.go's own SDK caching), so bumping the pinned version
|
|
97
|
-
triggers a fresh build rather than silently reusing a stale binary.
|
|
98
|
-
|
|
99
|
-
raise_if_env_exists has no default; see fogies.tools.environ.environ()
|
|
100
|
-
for why. Passed through to go().install()'s own GOBIN isolation.
|
|
101
|
-
"""
|
|
102
|
-
if version is None:
|
|
103
|
-
version = _DEFAULT_VERSION
|
|
104
|
-
|
|
105
|
-
if version not in _KNOWN_VERSIONS:
|
|
106
|
-
known = ", ".join(_KNOWN_VERSIONS)
|
|
107
|
-
raise ValueError(
|
|
108
|
-
"Unknown claude-sync version '{}'; known versions: {}".format(
|
|
109
|
-
version, known
|
|
110
|
-
)
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
gobin_path = binary_cache_path / "claude_sync_{}".format(version.replace(".", "_"))
|
|
114
|
-
exe_path = gobin_path / "claude-sync.exe"
|
|
115
|
-
|
|
116
|
-
if not exe_path.exists():
|
|
117
|
-
with go(binary_cache_path=binary_cache_path) as go_tool:
|
|
118
|
-
_ = go_tool.install(
|
|
119
|
-
command_params=CommandParams(in_stream=False),
|
|
120
|
-
module=_MODULE_TEMPLATE.format(version=version),
|
|
121
|
-
gobin_path=gobin_path,
|
|
122
|
-
raise_if_env_exists=raise_if_env_exists,
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
if not exe_path.exists():
|
|
126
|
-
raise RuntimeError(
|
|
127
|
-
"claude-sync executable not found in '{}'".format(gobin_path)
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
yield _ClaudeSync(path=exe_path)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/includes/templates/backend_status.toml.template
RENAMED
|
File without changes
|
{fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/includes/templates/poetry.toml.template
RENAMED
|
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
|