study-sync 1.0.2__tar.gz → 1.0.3__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.
- {study_sync-1.0.2 → study_sync-1.0.3}/PKG-INFO +1 -1
- {study_sync-1.0.2 → study_sync-1.0.3}/pyproject.toml +1 -1
- {study_sync-1.0.2 → study_sync-1.0.3}/study_sync.egg-info/PKG-INFO +1 -1
- study_sync-1.0.3/studysync/main.py +373 -0
- study_sync-1.0.2/studysync/main.py +0 -261
- {study_sync-1.0.2 → study_sync-1.0.3}/README.md +0 -0
- {study_sync-1.0.2 → study_sync-1.0.3}/setup.cfg +0 -0
- {study_sync-1.0.2 → study_sync-1.0.3}/study_sync.egg-info/SOURCES.txt +0 -0
- {study_sync-1.0.2 → study_sync-1.0.3}/study_sync.egg-info/dependency_links.txt +0 -0
- {study_sync-1.0.2 → study_sync-1.0.3}/study_sync.egg-info/entry_points.txt +0 -0
- {study_sync-1.0.2 → study_sync-1.0.3}/study_sync.egg-info/requires.txt +0 -0
- {study_sync-1.0.2 → study_sync-1.0.3}/study_sync.egg-info/top_level.txt +0 -0
- {study_sync-1.0.2 → study_sync-1.0.3}/studysync/__init__.py +0 -0
- {study_sync-1.0.2 → study_sync-1.0.3}/studysync/constants.py +0 -0
- {study_sync-1.0.2 → study_sync-1.0.3}/studysync/local_state.py +0 -0
- {study_sync-1.0.2 → study_sync-1.0.3}/studysync/sync_engine.py +0 -0
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
# ---------------------------------------------------------------------------
|
|
8
8
|
[project]
|
|
9
9
|
name = "study_sync"
|
|
10
|
-
version = "1.0.
|
|
10
|
+
version = "1.0.3"
|
|
11
11
|
description = "Offline-first, distributed workspace synchronisation CLI for developers."
|
|
12
12
|
license = { text = "MIT" }
|
|
13
13
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""
|
|
2
|
+
main.py — Rich-enhanced Typer CLI entry point for the StudySync `study` command.
|
|
3
|
+
|
|
4
|
+
Install:
|
|
5
|
+
cd cli && pip install -e .
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
study workspace create <name>
|
|
9
|
+
study join <token>
|
|
10
|
+
study pull
|
|
11
|
+
study push <file_path>
|
|
12
|
+
study status
|
|
13
|
+
study config
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
from importlib.metadata import PackageNotFoundError
|
|
20
|
+
from importlib.metadata import version as _pkg_version
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
from rich import box
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
from rich.padding import Padding
|
|
28
|
+
from rich.panel import Panel
|
|
29
|
+
from rich.rule import Rule
|
|
30
|
+
from rich.table import Table
|
|
31
|
+
from rich.text import Text
|
|
32
|
+
|
|
33
|
+
from .constants import PRODUCTION_SERVER_URL
|
|
34
|
+
from .local_state import (
|
|
35
|
+
WORKSPACES_DIR,
|
|
36
|
+
ensure_dirs,
|
|
37
|
+
load_config,
|
|
38
|
+
save_config,
|
|
39
|
+
workspace_root,
|
|
40
|
+
)
|
|
41
|
+
from .sync_engine import SyncEngine
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Version helper
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
__version__ = _pkg_version("study_sync")
|
|
49
|
+
except PackageNotFoundError:
|
|
50
|
+
__version__ = "dev"
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Globals
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
console = Console()
|
|
57
|
+
|
|
58
|
+
# Points at the public production backend so users who install via
|
|
59
|
+
# `pip install study_sync` work immediately without needing --server.
|
|
60
|
+
DEFAULT_SERVER = PRODUCTION_SERVER_URL
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Typer app skeleton
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
app = typer.Typer(
|
|
67
|
+
name="study",
|
|
68
|
+
help="StudySync — offline-first CLI workspace synchronisation.",
|
|
69
|
+
rich_markup_mode="rich",
|
|
70
|
+
# invoke_without_command=True so our callback can show the custom help panel
|
|
71
|
+
invoke_without_command=True,
|
|
72
|
+
add_completion=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
workspace_app = typer.Typer(
|
|
76
|
+
help="Manage workspaces.",
|
|
77
|
+
invoke_without_command=True,
|
|
78
|
+
no_args_is_help=True,
|
|
79
|
+
)
|
|
80
|
+
app.add_typer(workspace_app, name="workspace")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Custom no-args help panel
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _print_help_panel() -> None:
|
|
88
|
+
"""Render a beautiful Rich help panel when `study` is called with no args."""
|
|
89
|
+
|
|
90
|
+
# ── Header ──────────────────────────────────────────────────────────────
|
|
91
|
+
console.print()
|
|
92
|
+
console.print(
|
|
93
|
+
Panel(
|
|
94
|
+
Text.assemble(
|
|
95
|
+
("StudySync", "bold cyan"),
|
|
96
|
+
(" v" + __version__, "dim"),
|
|
97
|
+
"\nOffline-first, distributed workspace sync for developers.\n",
|
|
98
|
+
(DEFAULT_SERVER, "dim italic"),
|
|
99
|
+
),
|
|
100
|
+
border_style="cyan",
|
|
101
|
+
padding=(0, 2),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# ── Commands table ───────────────────────────────────────────────────────
|
|
106
|
+
tbl = Table(box=box.SIMPLE, show_header=True, header_style="bold blue", padding=(0, 2))
|
|
107
|
+
tbl.add_column("Command", style="cyan", no_wrap=True)
|
|
108
|
+
tbl.add_column("Description")
|
|
109
|
+
|
|
110
|
+
commands = [
|
|
111
|
+
("study workspace create [NAME]", "Create a new workspace and print its share token"),
|
|
112
|
+
("study join [TOKEN]", "Join a workspace using its share token"),
|
|
113
|
+
("study pull", "Download new/changed files from the remote workspace"),
|
|
114
|
+
("study push [FILE]", "Upload a local file to the remote workspace"),
|
|
115
|
+
("study status", "Show the sync state of every tracked file"),
|
|
116
|
+
("study config", "Display active workspace configuration"),
|
|
117
|
+
]
|
|
118
|
+
for cmd, desc in commands:
|
|
119
|
+
tbl.add_row(cmd, desc)
|
|
120
|
+
|
|
121
|
+
console.print(Rule("[bold]Commands[/bold]", style="dim"))
|
|
122
|
+
console.print(Padding(tbl, (0, 0)))
|
|
123
|
+
|
|
124
|
+
# ── Quick start ──────────────────────────────────────────────────────────
|
|
125
|
+
console.print(Rule("[bold]Quick start[/bold]", style="dim"))
|
|
126
|
+
console.print(
|
|
127
|
+
Padding(
|
|
128
|
+
"[dim]1.[/dim] [cyan]pip install study_sync[/cyan]\n"
|
|
129
|
+
"[dim]2.[/dim] [cyan]study join <TOKEN>[/cyan]\n"
|
|
130
|
+
"[dim]3.[/dim] [cyan]study pull[/cyan]\n\n"
|
|
131
|
+
"Type [cyan]study <command> --help[/cyan] for per-command options.",
|
|
132
|
+
(0, 4, 1, 4),
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.callback()
|
|
138
|
+
def _root_callback(ctx: typer.Context) -> None:
|
|
139
|
+
"""Show the help panel when `study` is run with no subcommand."""
|
|
140
|
+
if ctx.invoked_subcommand is None:
|
|
141
|
+
_print_help_panel()
|
|
142
|
+
raise typer.Exit()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ===========================================================================
|
|
146
|
+
# study workspace create <name>
|
|
147
|
+
# ===========================================================================
|
|
148
|
+
|
|
149
|
+
@workspace_app.command("create")
|
|
150
|
+
def workspace_create(
|
|
151
|
+
name: str = typer.Argument(
|
|
152
|
+
...,
|
|
153
|
+
help="Unique workspace name to create on the server.",
|
|
154
|
+
),
|
|
155
|
+
server: str = typer.Option(
|
|
156
|
+
DEFAULT_SERVER,
|
|
157
|
+
"--server", "-s",
|
|
158
|
+
help="Base URL of the StudySync server (default: production).",
|
|
159
|
+
envvar="STUDYSYNC_SERVER",
|
|
160
|
+
show_default=False,
|
|
161
|
+
),
|
|
162
|
+
) -> None:
|
|
163
|
+
"""
|
|
164
|
+
Create a new workspace and receive a shareable access token.
|
|
165
|
+
|
|
166
|
+
The printed token is the only credential for joining this workspace —
|
|
167
|
+
copy it and share it with collaborators via [cyan]study join <TOKEN>[/cyan].
|
|
168
|
+
"""
|
|
169
|
+
ensure_dirs()
|
|
170
|
+
engine = SyncEngine(server_url=server)
|
|
171
|
+
|
|
172
|
+
with console.status(
|
|
173
|
+
f"[blue]Creating workspace [bold]{name!r}[/bold]…[/blue]",
|
|
174
|
+
spinner="dots",
|
|
175
|
+
):
|
|
176
|
+
result = engine.create_workspace(name)
|
|
177
|
+
|
|
178
|
+
token: str = result["access_token"]
|
|
179
|
+
workspace_id: str = result["workspace_id"]
|
|
180
|
+
|
|
181
|
+
save_config(
|
|
182
|
+
{
|
|
183
|
+
"server_url": server,
|
|
184
|
+
"workspace_name": name,
|
|
185
|
+
"workspace_id": workspace_id,
|
|
186
|
+
"workspace_token": token,
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
workspace_root(name)
|
|
190
|
+
|
|
191
|
+
console.print(
|
|
192
|
+
Panel(
|
|
193
|
+
f"[bold]Workspace[/bold] {name}\n"
|
|
194
|
+
f"[bold]Token [/bold] [yellow]{token}[/yellow]\n\n"
|
|
195
|
+
f"Share with collaborators:\n"
|
|
196
|
+
f" [cyan]study join {token}[/cyan]",
|
|
197
|
+
title="[bold green]✓ Workspace created[/bold green]",
|
|
198
|
+
border_style="green",
|
|
199
|
+
padding=(1, 2),
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ===========================================================================
|
|
205
|
+
# study join <token>
|
|
206
|
+
# ===========================================================================
|
|
207
|
+
|
|
208
|
+
@app.command()
|
|
209
|
+
def join(
|
|
210
|
+
token: str = typer.Argument(
|
|
211
|
+
...,
|
|
212
|
+
help="Workspace access token (UUID) shared by the workspace owner.",
|
|
213
|
+
),
|
|
214
|
+
server: str = typer.Option(
|
|
215
|
+
DEFAULT_SERVER,
|
|
216
|
+
"--server", "-s",
|
|
217
|
+
help="Base URL of the StudySync server (default: production).",
|
|
218
|
+
envvar="STUDYSYNC_SERVER",
|
|
219
|
+
show_default=False,
|
|
220
|
+
),
|
|
221
|
+
) -> None:
|
|
222
|
+
"""
|
|
223
|
+
Join an existing workspace using its access token.
|
|
224
|
+
|
|
225
|
+
Validates the token against the server, then saves the workspace
|
|
226
|
+
credentials locally. Run [cyan]study pull[/cyan] afterwards to
|
|
227
|
+
download all files.
|
|
228
|
+
"""
|
|
229
|
+
ensure_dirs()
|
|
230
|
+
engine = SyncEngine(server_url=server)
|
|
231
|
+
|
|
232
|
+
with console.status("[blue]Validating token…[/blue]", spinner="dots"):
|
|
233
|
+
result = engine.join_workspace(token)
|
|
234
|
+
|
|
235
|
+
workspace_name: str = result["name"]
|
|
236
|
+
workspace_id: str = result["workspace_id"]
|
|
237
|
+
|
|
238
|
+
save_config(
|
|
239
|
+
{
|
|
240
|
+
"server_url": server,
|
|
241
|
+
"workspace_name": workspace_name,
|
|
242
|
+
"workspace_id": workspace_id,
|
|
243
|
+
"workspace_token": token,
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
workspace_root(workspace_name)
|
|
247
|
+
|
|
248
|
+
console.print(
|
|
249
|
+
Panel(
|
|
250
|
+
f"[bold]Workspace[/bold] {workspace_name}\n"
|
|
251
|
+
f"[bold]Server [/bold] {server}\n\n"
|
|
252
|
+
f"Run [cyan]study pull[/cyan] to download all files.",
|
|
253
|
+
title="[bold green]✓ Joined workspace[/bold green]",
|
|
254
|
+
border_style="green",
|
|
255
|
+
padding=(1, 2),
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ===========================================================================
|
|
261
|
+
# study pull
|
|
262
|
+
# ===========================================================================
|
|
263
|
+
|
|
264
|
+
@app.command()
|
|
265
|
+
def pull() -> None:
|
|
266
|
+
"""
|
|
267
|
+
Download all new or updated files from the remote workspace.
|
|
268
|
+
|
|
269
|
+
Compares remote file versions and checksums against the local manifest,
|
|
270
|
+
downloads only what has changed, and verifies each file's SHA-256 hash.
|
|
271
|
+
Updated files are written to the current directory.
|
|
272
|
+
"""
|
|
273
|
+
console.rule("[bold blue]study pull[/bold blue]", style="dim blue")
|
|
274
|
+
SyncEngine().pull()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ===========================================================================
|
|
278
|
+
# study push <file_path>
|
|
279
|
+
# ===========================================================================
|
|
280
|
+
|
|
281
|
+
@app.command()
|
|
282
|
+
def push(
|
|
283
|
+
file_path: str = typer.Argument(
|
|
284
|
+
...,
|
|
285
|
+
help="Path to the local file to upload (relative or absolute).",
|
|
286
|
+
),
|
|
287
|
+
server: str = typer.Option(
|
|
288
|
+
None,
|
|
289
|
+
"--server",
|
|
290
|
+
help="Override the server URL for this push only.",
|
|
291
|
+
envvar="STUDYSYNC_SERVER",
|
|
292
|
+
show_default=False,
|
|
293
|
+
),
|
|
294
|
+
) -> None:
|
|
295
|
+
"""
|
|
296
|
+
Upload a local file to the remote workspace.
|
|
297
|
+
|
|
298
|
+
Computes a SHA-256 checksum and base version before uploading, so
|
|
299
|
+
the server can reject concurrent conflicting writes (optimistic
|
|
300
|
+
concurrency control). If the file has not changed since the last push,
|
|
301
|
+
the upload is skipped.
|
|
302
|
+
"""
|
|
303
|
+
console.rule("[bold blue]study push[/bold blue]", style="dim blue")
|
|
304
|
+
SyncEngine(server_url=server).push(file_path)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ===========================================================================
|
|
308
|
+
# study status
|
|
309
|
+
# ===========================================================================
|
|
310
|
+
|
|
311
|
+
@app.command()
|
|
312
|
+
def status() -> None:
|
|
313
|
+
"""
|
|
314
|
+
Show the sync status of every file in the local workspace.
|
|
315
|
+
|
|
316
|
+
[green]CLEAN[/green] — matches the last-known server version.
|
|
317
|
+
[yellow]MODIFIED[/yellow] — changed locally since last push/pull.
|
|
318
|
+
[red]DELETED[/red] — tracked but missing from disk.
|
|
319
|
+
[blue]UNTRACKED[/blue] — on disk but never pushed.
|
|
320
|
+
"""
|
|
321
|
+
console.rule("[bold blue]study status[/bold blue]", style="dim blue")
|
|
322
|
+
SyncEngine().status()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ===========================================================================
|
|
326
|
+
# study config
|
|
327
|
+
# ===========================================================================
|
|
328
|
+
|
|
329
|
+
@app.command()
|
|
330
|
+
def config() -> None:
|
|
331
|
+
"""Display the active workspace configuration stored in ~/.study/config.json."""
|
|
332
|
+
cfg = load_config()
|
|
333
|
+
if not cfg:
|
|
334
|
+
console.print(
|
|
335
|
+
Panel(
|
|
336
|
+
"No workspace is configured.\n\n"
|
|
337
|
+
"Create one: [cyan]study workspace create <name>[/cyan]\n"
|
|
338
|
+
"Or join one: [cyan]study join <token>[/cyan]",
|
|
339
|
+
title="[bold yellow]⚠ Not configured[/bold yellow]",
|
|
340
|
+
border_style="yellow",
|
|
341
|
+
padding=(1, 2),
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
raise typer.Exit(1)
|
|
345
|
+
|
|
346
|
+
workspace_name = cfg.get("workspace_name", "N/A")
|
|
347
|
+
local_path = WORKSPACES_DIR / workspace_name if workspace_name != "N/A" else "N/A"
|
|
348
|
+
|
|
349
|
+
rows = [
|
|
350
|
+
("Workspace", workspace_name),
|
|
351
|
+
("Server", cfg.get("server_url", "N/A")),
|
|
352
|
+
("Token", cfg.get("workspace_token", "N/A")),
|
|
353
|
+
("Workspace ID", cfg.get("workspace_id", "N/A")),
|
|
354
|
+
("Local path", str(local_path)),
|
|
355
|
+
]
|
|
356
|
+
body = "\n".join(f"[bold]{k:<14}[/bold] {v}" for k, v in rows)
|
|
357
|
+
|
|
358
|
+
console.print(
|
|
359
|
+
Panel(
|
|
360
|
+
body,
|
|
361
|
+
title="[bold blue]StudySync Config[/bold blue] (~/.study/config.json)",
|
|
362
|
+
border_style="blue",
|
|
363
|
+
padding=(1, 2),
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# Entry point
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
app()
|
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
main.py — Typer CLI entry point for the StudySync `study` command.
|
|
3
|
-
|
|
4
|
-
Install:
|
|
5
|
-
cd cli && pip install -e .
|
|
6
|
-
|
|
7
|
-
Usage:
|
|
8
|
-
study workspace create <name> [--server <url>]
|
|
9
|
-
study join <token> [--server <url>]
|
|
10
|
-
study pull
|
|
11
|
-
study push <file_path>
|
|
12
|
-
study status
|
|
13
|
-
study config
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
import sys
|
|
19
|
-
from pathlib import Path
|
|
20
|
-
from typing import Optional
|
|
21
|
-
|
|
22
|
-
import typer
|
|
23
|
-
from rich.console import Console
|
|
24
|
-
from rich.panel import Panel
|
|
25
|
-
|
|
26
|
-
from .constants import PRODUCTION_SERVER_URL
|
|
27
|
-
from .local_state import (
|
|
28
|
-
WORKSPACES_DIR,
|
|
29
|
-
ensure_dirs,
|
|
30
|
-
load_config,
|
|
31
|
-
save_config,
|
|
32
|
-
workspace_root,
|
|
33
|
-
)
|
|
34
|
-
from .sync_engine import SyncEngine
|
|
35
|
-
|
|
36
|
-
# ---------------------------------------------------------------------------
|
|
37
|
-
# App skeleton
|
|
38
|
-
# ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
app = typer.Typer(
|
|
41
|
-
name="study",
|
|
42
|
-
help="[bold]StudySync[/bold] — offline-first CLI workspace synchronisation.",
|
|
43
|
-
rich_markup_mode="rich",
|
|
44
|
-
no_args_is_help=True,
|
|
45
|
-
add_completion=True,
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
workspace_app = typer.Typer(
|
|
49
|
-
help="Manage workspaces (create, list).",
|
|
50
|
-
no_args_is_help=True,
|
|
51
|
-
)
|
|
52
|
-
app.add_typer(workspace_app, name="workspace")
|
|
53
|
-
|
|
54
|
-
console = Console()
|
|
55
|
-
|
|
56
|
-
# Default gateway — points at the public production backend so users who
|
|
57
|
-
# install via `pip install studysync` work immediately without --server.
|
|
58
|
-
# Override with --server for self-hosted deployments.
|
|
59
|
-
DEFAULT_SERVER = PRODUCTION_SERVER_URL
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
# ===========================================================================
|
|
63
|
-
# study workspace create <name>
|
|
64
|
-
# ===========================================================================
|
|
65
|
-
|
|
66
|
-
@workspace_app.command("create")
|
|
67
|
-
def workspace_create(
|
|
68
|
-
name: str = typer.Argument(..., help="Unique workspace name to create on the server."),
|
|
69
|
-
server: str = typer.Option(
|
|
70
|
-
DEFAULT_SERVER,
|
|
71
|
-
"--server",
|
|
72
|
-
"-s",
|
|
73
|
-
help="Base URL of the StudySync server.",
|
|
74
|
-
envvar="STUDYSYNC_SERVER",
|
|
75
|
-
),
|
|
76
|
-
) -> None:
|
|
77
|
-
"""
|
|
78
|
-
Create a new workspace on the server and save the returned token locally.
|
|
79
|
-
|
|
80
|
-
The printed token is the only credential for joining this workspace —
|
|
81
|
-
share it with collaborators.
|
|
82
|
-
"""
|
|
83
|
-
ensure_dirs()
|
|
84
|
-
engine = SyncEngine(server_url=server)
|
|
85
|
-
|
|
86
|
-
with console.status(f"[blue]Creating workspace '[bold]{name}[/bold]'…[/blue]"):
|
|
87
|
-
result = engine.create_workspace(name)
|
|
88
|
-
|
|
89
|
-
token: str = result["access_token"]
|
|
90
|
-
workspace_id: str = result["workspace_id"]
|
|
91
|
-
|
|
92
|
-
save_config(
|
|
93
|
-
{
|
|
94
|
-
"server_url": server,
|
|
95
|
-
"workspace_name": name,
|
|
96
|
-
"workspace_id": workspace_id,
|
|
97
|
-
"workspace_token": token,
|
|
98
|
-
}
|
|
99
|
-
)
|
|
100
|
-
workspace_root(name) # ensure local directory exists
|
|
101
|
-
|
|
102
|
-
console.print(
|
|
103
|
-
Panel(
|
|
104
|
-
f"[green]Workspace '[bold]{name}[/bold]' created.[/green]\n\n"
|
|
105
|
-
f"[bold]Token[/bold] [yellow]{token}[/yellow]\n\n"
|
|
106
|
-
f"Share this token with collaborators:\n"
|
|
107
|
-
f" [cyan]study join {token}[/cyan]",
|
|
108
|
-
title="✓ Workspace Created",
|
|
109
|
-
border_style="green",
|
|
110
|
-
padding=(1, 2),
|
|
111
|
-
)
|
|
112
|
-
)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
# ===========================================================================
|
|
116
|
-
# study join <token>
|
|
117
|
-
# ===========================================================================
|
|
118
|
-
|
|
119
|
-
@app.command()
|
|
120
|
-
def join(
|
|
121
|
-
token: str = typer.Argument(..., help="Workspace access token (UUID)."),
|
|
122
|
-
server: str = typer.Option(
|
|
123
|
-
DEFAULT_SERVER,
|
|
124
|
-
"--server",
|
|
125
|
-
"-s",
|
|
126
|
-
help="Base URL of the StudySync server.",
|
|
127
|
-
envvar="STUDYSYNC_SERVER",
|
|
128
|
-
),
|
|
129
|
-
) -> None:
|
|
130
|
-
"""
|
|
131
|
-
Validate a workspace token and set it as the active workspace.
|
|
132
|
-
|
|
133
|
-
Run [cyan]study pull[/cyan] afterwards to download existing files.
|
|
134
|
-
"""
|
|
135
|
-
ensure_dirs()
|
|
136
|
-
engine = SyncEngine(server_url=server)
|
|
137
|
-
|
|
138
|
-
with console.status("[blue]Validating token…[/blue]"):
|
|
139
|
-
result = engine.join_workspace(token)
|
|
140
|
-
|
|
141
|
-
workspace_name: str = result["name"]
|
|
142
|
-
workspace_id: str = result["workspace_id"]
|
|
143
|
-
|
|
144
|
-
save_config(
|
|
145
|
-
{
|
|
146
|
-
"server_url": server,
|
|
147
|
-
"workspace_name": workspace_name,
|
|
148
|
-
"workspace_id": workspace_id,
|
|
149
|
-
"workspace_token": token,
|
|
150
|
-
}
|
|
151
|
-
)
|
|
152
|
-
workspace_root(workspace_name)
|
|
153
|
-
|
|
154
|
-
console.print(
|
|
155
|
-
Panel(
|
|
156
|
-
f"[green]Joined workspace '[bold]{workspace_name}[/bold]'.[/green]\n\n"
|
|
157
|
-
f"Run [cyan]study pull[/cyan] to download all files.",
|
|
158
|
-
title="✓ Joined",
|
|
159
|
-
border_style="green",
|
|
160
|
-
padding=(1, 2),
|
|
161
|
-
)
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
# ===========================================================================
|
|
166
|
-
# study pull
|
|
167
|
-
# ===========================================================================
|
|
168
|
-
|
|
169
|
-
@app.command()
|
|
170
|
-
def pull() -> None:
|
|
171
|
-
"""
|
|
172
|
-
Pull all new or updated files from the remote workspace.
|
|
173
|
-
|
|
174
|
-
Compares the remote file tree (versions + checksums) against the local
|
|
175
|
-
manifest and downloads only what has changed. Local file hashes are
|
|
176
|
-
verified after download.
|
|
177
|
-
"""
|
|
178
|
-
SyncEngine().pull()
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
# ===========================================================================
|
|
182
|
-
# study push <file_path>
|
|
183
|
-
# ===========================================================================
|
|
184
|
-
|
|
185
|
-
@app.command()
|
|
186
|
-
def push(
|
|
187
|
-
file_path: str = typer.Argument(
|
|
188
|
-
...,
|
|
189
|
-
help=(
|
|
190
|
-
"Path to the file to push. "
|
|
191
|
-
"Can be workspace-relative ('src/main.py') "
|
|
192
|
-
"or an OS path ('/home/user/project/main.py')."
|
|
193
|
-
),
|
|
194
|
-
),
|
|
195
|
-
) -> None:
|
|
196
|
-
"""
|
|
197
|
-
Push a local file to the remote workspace.
|
|
198
|
-
|
|
199
|
-
Enforces Optimistic Concurrency Control — if the remote has a newer
|
|
200
|
-
version than your local base, the push is rejected with a [red]CONFLICT[/red]
|
|
201
|
-
warning and you must run [cyan]study pull[/cyan] first.
|
|
202
|
-
"""
|
|
203
|
-
SyncEngine().push(file_path)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
# ===========================================================================
|
|
207
|
-
# study status
|
|
208
|
-
# ===========================================================================
|
|
209
|
-
|
|
210
|
-
@app.command()
|
|
211
|
-
def status() -> None:
|
|
212
|
-
"""
|
|
213
|
-
Show the sync status of every file in the local workspace.
|
|
214
|
-
|
|
215
|
-
[green]CLEAN[/green] — matches the last-known server version.
|
|
216
|
-
[yellow]MODIFIED[/yellow] — changed locally since last push/pull.
|
|
217
|
-
[red]DELETED[/red] — tracked but missing on disk.
|
|
218
|
-
[blue]UNTRACKED[/blue] — on disk but not yet pushed.
|
|
219
|
-
"""
|
|
220
|
-
SyncEngine().status()
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
# ===========================================================================
|
|
224
|
-
# study config
|
|
225
|
-
# ===========================================================================
|
|
226
|
-
|
|
227
|
-
@app.command()
|
|
228
|
-
def config() -> None:
|
|
229
|
-
"""Show the current workspace configuration stored in ~/.study/config.json."""
|
|
230
|
-
cfg = load_config()
|
|
231
|
-
if not cfg:
|
|
232
|
-
console.print(
|
|
233
|
-
"[yellow]No workspace configured. "
|
|
234
|
-
"Run [cyan]study workspace create <name>[/cyan] or "
|
|
235
|
-
"[cyan]study join <token>[/cyan].[/yellow]"
|
|
236
|
-
)
|
|
237
|
-
raise typer.Exit(1)
|
|
238
|
-
|
|
239
|
-
lines = [
|
|
240
|
-
f"[bold]Workspace [/bold] {cfg.get('workspace_name', 'N/A')}",
|
|
241
|
-
f"[bold]Server [/bold] {cfg.get('server_url', 'N/A')}",
|
|
242
|
-
f"[bold]Token [/bold] [yellow]{cfg.get('workspace_token', 'N/A')}[/yellow]",
|
|
243
|
-
f"[bold]Workspace ID[/bold] {cfg.get('workspace_id', 'N/A')}",
|
|
244
|
-
f"[bold]Local path [/bold] {WORKSPACES_DIR / cfg.get('workspace_name', '')}",
|
|
245
|
-
]
|
|
246
|
-
console.print(
|
|
247
|
-
Panel(
|
|
248
|
-
"\n".join(lines),
|
|
249
|
-
title="StudySync Config (~/.study/config.json)",
|
|
250
|
-
border_style="blue",
|
|
251
|
-
padding=(1, 2),
|
|
252
|
-
)
|
|
253
|
-
)
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
# ---------------------------------------------------------------------------
|
|
257
|
-
# Entry point (for `python -m studysync.main`)
|
|
258
|
-
# ---------------------------------------------------------------------------
|
|
259
|
-
|
|
260
|
-
if __name__ == "__main__":
|
|
261
|
-
app()
|
|
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
|