teambridge-candles 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- teambridge_candles-1.0.0/PKG-INFO +9 -0
- teambridge_candles-1.0.0/candles/__init__.py +0 -0
- teambridge_candles-1.0.0/candles/auth.py +61 -0
- teambridge_candles-1.0.0/candles/main.py +383 -0
- teambridge_candles-1.0.0/candles/tracker.py +314 -0
- teambridge_candles-1.0.0/setup.cfg +4 -0
- teambridge_candles-1.0.0/setup.py +23 -0
- teambridge_candles-1.0.0/teambridge_candles.egg-info/PKG-INFO +9 -0
- teambridge_candles-1.0.0/teambridge_candles.egg-info/SOURCES.txt +11 -0
- teambridge_candles-1.0.0/teambridge_candles.egg-info/dependency_links.txt +1 -0
- teambridge_candles-1.0.0/teambridge_candles.egg-info/entry_points.txt +5 -0
- teambridge_candles-1.0.0/teambridge_candles.egg-info/requires.txt +5 -0
- teambridge_candles-1.0.0/teambridge_candles.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: teambridge-candles
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Requires-Dist: requests>=2.31.0
|
|
5
|
+
Requires-Dist: click>=8.1.0
|
|
6
|
+
Requires-Dist: watchdog>=3.0.0
|
|
7
|
+
Requires-Dist: python-socketio[client]>=5.11.0
|
|
8
|
+
Requires-Dist: inquirer>=3.1.3
|
|
9
|
+
Dynamic: requires-dist
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# 📄 Location: d:/Ptojects/TeamBridge/cli/candles/auth.py
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
HOME_DIR = str(Path.home())
|
|
8
|
+
CONFIG_DIR = os.path.join(HOME_DIR, '.candles')
|
|
9
|
+
CONFIG_PATH = os.path.join(CONFIG_DIR, 'config.json')
|
|
10
|
+
|
|
11
|
+
def save_session(token, email, team_code=None, project_name=None):
|
|
12
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
13
|
+
config_data = {
|
|
14
|
+
"auth_token": token,
|
|
15
|
+
"user_email": email,
|
|
16
|
+
"device_name": socket.gethostname()
|
|
17
|
+
}
|
|
18
|
+
if team_code:
|
|
19
|
+
config_data["team_code"] = team_code
|
|
20
|
+
if project_name:
|
|
21
|
+
config_data["project_name"] = project_name
|
|
22
|
+
|
|
23
|
+
with open(CONFIG_PATH, 'w') as f:
|
|
24
|
+
json.dump(config_data, f, indent=4)
|
|
25
|
+
|
|
26
|
+
def load_session():
|
|
27
|
+
if not os.path.exists(CONFIG_PATH):
|
|
28
|
+
return None
|
|
29
|
+
with open(CONFIG_PATH, 'r') as f:
|
|
30
|
+
return json.load(f)
|
|
31
|
+
|
|
32
|
+
def clear_session():
|
|
33
|
+
if os.path.exists(CONFIG_PATH):
|
|
34
|
+
try:
|
|
35
|
+
os.remove(CONFIG_PATH)
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def save_local_context(team_code, project_name):
|
|
40
|
+
"""Saves project reference context inside the current working directory."""
|
|
41
|
+
local_dir = os.path.join(os.getcwd(), '.candles')
|
|
42
|
+
os.makedirs(local_dir, exist_ok=True)
|
|
43
|
+
local_path = os.path.join(local_dir, 'project_context.json')
|
|
44
|
+
context_data = {
|
|
45
|
+
"team_code": team_code,
|
|
46
|
+
"project_name": project_name,
|
|
47
|
+
"initialized_at": os.getcwd()
|
|
48
|
+
}
|
|
49
|
+
with open(local_path, 'w') as f:
|
|
50
|
+
json.dump(context_data, f, indent=4)
|
|
51
|
+
|
|
52
|
+
def load_local_context():
|
|
53
|
+
"""Loads project reference context inside the current working directory."""
|
|
54
|
+
local_path = os.path.join(os.getcwd(), '.candles', 'project_context.json')
|
|
55
|
+
if not os.path.exists(local_path):
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
with open(local_path, 'r') as f:
|
|
59
|
+
return json.load(f)
|
|
60
|
+
except Exception:
|
|
61
|
+
return None
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# 📄 Location: d:/Ptojects/TeamBridge/cli/candles/main.py
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import click
|
|
5
|
+
import requests
|
|
6
|
+
import socket
|
|
7
|
+
import base64
|
|
8
|
+
import webbrowser
|
|
9
|
+
import inquirer
|
|
10
|
+
from .auth import save_session, load_session, clear_session, save_local_context, load_local_context
|
|
11
|
+
from .tracker import start_workspace_monitoring
|
|
12
|
+
|
|
13
|
+
BACKEND_URL = "https://candels.onrender.com/api/cli"
|
|
14
|
+
|
|
15
|
+
def get_headers(session):
|
|
16
|
+
return {
|
|
17
|
+
"Authorization": f"Bearer {session.get('auth_token')}"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
def print_base_folder_warning():
|
|
21
|
+
"""Prints warning about running commands in subfolders rather than base folders."""
|
|
22
|
+
click.echo(click.style("\n[WARNING] Candles should be initialized and linked only on the base/root folder", fg="yellow", bold=True))
|
|
23
|
+
click.echo(click.style("so that the full project folder and its files can be accessed easily and displayed on the web.", fg="yellow"))
|
|
24
|
+
|
|
25
|
+
def is_base_directory():
|
|
26
|
+
"""Helper to check if the current directory is likely a project base directory."""
|
|
27
|
+
base_indicators = ['.git', 'package.json', 'requirements.txt', 'README.md', 'app.py', 'index.html', 'candles.config']
|
|
28
|
+
return any(os.path.exists(os.path.join(os.getcwd(), ind)) for ind in base_indicators)
|
|
29
|
+
|
|
30
|
+
def check_internet_connection():
|
|
31
|
+
"""Quick socket routine to verify if public DNS route is accessible."""
|
|
32
|
+
try:
|
|
33
|
+
socket.create_connection(("8.8.8.8", 53), timeout=3)
|
|
34
|
+
return True
|
|
35
|
+
except OSError:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
@click.group()
|
|
39
|
+
def cli():
|
|
40
|
+
"""Candles Global CLI Tool - Evolve your workspace into a collaborative SaaS environment."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
@cli.command()
|
|
44
|
+
def create():
|
|
45
|
+
"""Redirect user to the website portal to configure structural team setups."""
|
|
46
|
+
click.echo(click.style("\n[Redirecting] Opening Candles Form Console...", fg="cyan", bold=True))
|
|
47
|
+
click.echo(click.style("Please create a project, confirm them and complete the declaration form to create project to use that in the local system.", fg="yellow"))
|
|
48
|
+
webbrowser.open("http://127.0.0.1:5000/create-project")
|
|
49
|
+
|
|
50
|
+
@cli.command()
|
|
51
|
+
def login():
|
|
52
|
+
"""Securely connect your terminal environment to your Candles profile with masked security inputs."""
|
|
53
|
+
click.echo(click.style("[Cloud] Connecting to Candles Cloud Services...", fg="cyan"))
|
|
54
|
+
|
|
55
|
+
email = click.prompt("Enter your registered email")
|
|
56
|
+
# hide_input=True replaces typed credentials with clean secure shell masking
|
|
57
|
+
password = click.prompt("Enter your password", hide_input=True)
|
|
58
|
+
device_name = socket.gethostname()
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
response = requests.post(
|
|
62
|
+
f"{BACKEND_URL}/login",
|
|
63
|
+
json={"email": email, "password": password, "device_name": device_name}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if response.status_code == 200:
|
|
67
|
+
result = response.json()
|
|
68
|
+
token = result.get("token")
|
|
69
|
+
|
|
70
|
+
# Save initialized global state profiles variables data models
|
|
71
|
+
save_session(token, email, None, None)
|
|
72
|
+
|
|
73
|
+
current_path = os.getcwd()
|
|
74
|
+
click.echo(click.style(f"\n[Success] Authenticated cleanly on path context: {current_path}>", fg="green", bold=True))
|
|
75
|
+
click.echo("You can now safely execute 'cn create', 'cn link', 'cn add' etc. across structural system pathways.")
|
|
76
|
+
else:
|
|
77
|
+
click.echo(click.style("\n[Error] Access Denied: Check password / email and re-enter again.", fg="red", bold=True))
|
|
78
|
+
|
|
79
|
+
except requests.exceptions.ConnectionError:
|
|
80
|
+
click.echo(click.style("[Error] Connection Error: Could not reach Flask backend server. Make sure your app is running!", fg="red"))
|
|
81
|
+
|
|
82
|
+
def login_script():
|
|
83
|
+
"""Wrapper entry point for cn-login command."""
|
|
84
|
+
from click.testing import CliRunner
|
|
85
|
+
cli(args=['login'])
|
|
86
|
+
|
|
87
|
+
def live_script():
|
|
88
|
+
"""Wrapper entry point for cn-live command running background synchronization loop."""
|
|
89
|
+
from click.testing import CliRunner
|
|
90
|
+
cli(args=['link'])
|
|
91
|
+
|
|
92
|
+
@cli.command()
|
|
93
|
+
def init():
|
|
94
|
+
"""Initialize a project workspace in the current folder."""
|
|
95
|
+
session = load_session()
|
|
96
|
+
if not session:
|
|
97
|
+
click.echo(click.style("[Warning] No active profile found. Please run 'cn login' first.", fg="yellow"))
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
team_code = session.get("team_code")
|
|
101
|
+
project_name = session.get("project_name")
|
|
102
|
+
|
|
103
|
+
if not team_code:
|
|
104
|
+
click.echo(click.style("[Error] No project selected in your profile. Run 'cn login' to select one.", fg="red"))
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
if not is_base_directory():
|
|
108
|
+
print_base_folder_warning()
|
|
109
|
+
if not click.confirm("Do you want to proceed with initialization in the current directory?", default=False):
|
|
110
|
+
click.echo(click.style("[Aborted] Initialization cancelled. Please navigate to the project base folder.", fg="red"))
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
save_local_context(team_code, project_name)
|
|
114
|
+
click.echo(click.style(f"[Local] Folder initialized and linked to Candles workspace: {project_name} ({team_code})", fg="green", bold=True))
|
|
115
|
+
|
|
116
|
+
@cli.command()
|
|
117
|
+
def get():
|
|
118
|
+
"""Download all scaffolding files and folders from the online repository."""
|
|
119
|
+
session = load_session()
|
|
120
|
+
local_ctx = load_local_context()
|
|
121
|
+
|
|
122
|
+
if not session:
|
|
123
|
+
click.echo(click.style("[Warning] No active profile found. Please run 'cn login' first.", fg="yellow"))
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
team_code = local_ctx.get("team_code") if local_ctx else session.get("team_code")
|
|
127
|
+
if not team_code:
|
|
128
|
+
click.echo(click.style("[Error] Directory not initialized. Run 'cn init' or 'cn login' first.", fg="red"))
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
click.echo(click.style(f"[Sync] Pulling files for Team: {team_code} from cloud...", fg="cyan"))
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
response = requests.get(
|
|
135
|
+
f"{BACKEND_URL}/files?team_code={team_code}",
|
|
136
|
+
headers=get_headers(session)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if response.status_code == 200:
|
|
140
|
+
files = response.json().get("files", [])
|
|
141
|
+
if not files:
|
|
142
|
+
click.echo(click.style("[Info] No files found in remote workspace yet.", fg="yellow"))
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
for file_data in files:
|
|
146
|
+
path = file_data["path"]
|
|
147
|
+
content = base64.b64decode(file_data["content"])
|
|
148
|
+
|
|
149
|
+
full_path = os.path.join(os.getcwd(), path)
|
|
150
|
+
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
|
151
|
+
with open(full_path, 'wb') as f:
|
|
152
|
+
f.write(content)
|
|
153
|
+
click.echo(f" [Downloading] {path}")
|
|
154
|
+
|
|
155
|
+
click.echo(click.style("[Success] Workspace download complete. Local folder matching cloud filesystem.", fg="green", bold=True))
|
|
156
|
+
else:
|
|
157
|
+
click.echo(click.style(f"[Error] Failed to fetch files: {response.text}", fg="red"))
|
|
158
|
+
|
|
159
|
+
except requests.exceptions.ConnectionError:
|
|
160
|
+
click.echo(click.style("[Error] Connection Error: Backend server is offline.", fg="red"))
|
|
161
|
+
|
|
162
|
+
@cli.command()
|
|
163
|
+
def link():
|
|
164
|
+
"""Upload local folder structure, allow custom project selection cloning, and sync changes real-time."""
|
|
165
|
+
session = load_session()
|
|
166
|
+
if not session:
|
|
167
|
+
click.echo(click.style("[Warning] No active profile found. Please run 'cn login' first.", fg="yellow"))
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# Connection environment indicator checks
|
|
171
|
+
if check_internet_connection():
|
|
172
|
+
click.echo(click.style("🌐 Connected [Online Real-time Pipeline Enabled]", fg="green"))
|
|
173
|
+
else:
|
|
174
|
+
click.echo(click.style("🛑 Disconnected [Offline Core Layer Running]", fg="red"))
|
|
175
|
+
|
|
176
|
+
local_ctx = load_local_context()
|
|
177
|
+
|
|
178
|
+
# If the user runs cn-link inside a directory that is already an active cloned repository context
|
|
179
|
+
if local_ctx:
|
|
180
|
+
team_code = local_ctx["team_code"]
|
|
181
|
+
project_name = local_ctx["project_name"]
|
|
182
|
+
|
|
183
|
+
click.echo(click.style(f"[Link] Linking current local directory to Candles cloud folder: {project_name}...", fg="cyan"))
|
|
184
|
+
|
|
185
|
+
if not is_base_directory():
|
|
186
|
+
print_base_folder_warning()
|
|
187
|
+
if not click.confirm("Are you sure you want to run link / synchronize here?"):
|
|
188
|
+
click.echo(click.style("[Aborted] Action aborted.", fg="red"))
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
click.echo(click.style("\n[Sync] Synchronizing local files to Cloud Workspace...", fg="cyan"))
|
|
192
|
+
ignored_folders = {'.git', 'node_modules', '__pycache__', '.candles', 'venv', 'env'}
|
|
193
|
+
|
|
194
|
+
for root, dirs, files in os.walk(os.getcwd()):
|
|
195
|
+
dirs[:] = [d for d in dirs if d not in ignored_folders]
|
|
196
|
+
for file in files:
|
|
197
|
+
full_path = os.path.join(root, file)
|
|
198
|
+
rel_path = os.path.relpath(full_path, os.getcwd()).replace('\\', '/')
|
|
199
|
+
|
|
200
|
+
canonical_root = os.path.realpath(os.getcwd())
|
|
201
|
+
canonical_file = os.path.realpath(full_path)
|
|
202
|
+
try:
|
|
203
|
+
if os.path.commonpath([canonical_root, canonical_file]) != canonical_root:
|
|
204
|
+
continue
|
|
205
|
+
except ValueError:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
file_size = os.path.getsize(canonical_file)
|
|
210
|
+
if file_size > 2 * 1024 * 1024:
|
|
211
|
+
click.echo(click.style(f" [Binary Guard] Skipping heavy file (>2MB): {rel_path}", fg="yellow"))
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
from .tracker import is_binary_file
|
|
215
|
+
if is_binary_file(canonical_file):
|
|
216
|
+
ext = os.path.splitext(rel_path)[1].lower()
|
|
217
|
+
if ext not in ['.docx', '.doc', '.pdf']:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
with open(full_path, 'rb') as f:
|
|
221
|
+
content_b64 = base64.b64encode(f.read()).decode('utf-8')
|
|
222
|
+
|
|
223
|
+
payload = {
|
|
224
|
+
"team_code": team_code,
|
|
225
|
+
"path": rel_path,
|
|
226
|
+
"content": content_b64
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
res = requests.post(f"{BACKEND_URL}/upload", json=payload, headers=get_headers(session))
|
|
230
|
+
if res.status_code == 200:
|
|
231
|
+
click.echo(f" [Syncing] {rel_path}")
|
|
232
|
+
except Exception as e:
|
|
233
|
+
click.echo(click.style(f" [Warning] Skipping file {rel_path}: {e}", fg="yellow"))
|
|
234
|
+
|
|
235
|
+
click.echo(click.style("[Success] Initial sync complete. Watching for local modifications...", fg="green"))
|
|
236
|
+
start_workspace_monitoring(team_code, session["auth_token"])
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
# If run in an unlinked/new folder (e.g., testfolder), prompt user to select and download an existing database project
|
|
240
|
+
click.echo(click.style("\n[Fetch] Contacting database to fetch accessible active user projects...", fg="cyan"))
|
|
241
|
+
try:
|
|
242
|
+
response = requests.get(f"{BACKEND_URL}/projects", headers=get_headers(session))
|
|
243
|
+
if response.status_code != 200:
|
|
244
|
+
click.echo(click.style("[Error] Failed to fetch active projects from backend registry.", fg="red"))
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
projects = response.json().get("projects", [])
|
|
248
|
+
if not projects:
|
|
249
|
+
click.echo(click.style("[Info] No remote projects found. Use 'cn create' to set up a new workspace registry.", fg="yellow"))
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
# Use terminal arrow selections key mappings arrays maps
|
|
253
|
+
project_map = {f"{p['project_title']} (Code: {p['team_code']})": p for p in projects}
|
|
254
|
+
choices_list = list(project_map.keys())
|
|
255
|
+
|
|
256
|
+
questions = [
|
|
257
|
+
inquirer.List(
|
|
258
|
+
'chosen_project_str',
|
|
259
|
+
message="Select a project workspace (Use Up/Down Arrows, Space/Enter to confirm choice)",
|
|
260
|
+
choices=choices_list,
|
|
261
|
+
)
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
answers = inquirer.prompt(questions)
|
|
265
|
+
if not answers:
|
|
266
|
+
click.echo(click.style("[Aborted] No workspace project targeted.", fg="red"))
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
selected_record = project_map[answers['chosen_project_str']]
|
|
270
|
+
team_code = selected_record["team_code"]
|
|
271
|
+
project_name = selected_record["project_title"]
|
|
272
|
+
|
|
273
|
+
if not click.confirm(f"\nDo you allow Candles to clone project database entries here inside folder context?"):
|
|
274
|
+
click.echo(click.style("[Aborted] Core cloning sequence cancelled.", fg="red"))
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
# Dynamically allocate cloning directory matching exactly your custom project form text
|
|
278
|
+
target_clone_folder = os.path.join(os.getcwd(), project_name)
|
|
279
|
+
os.makedirs(target_clone_folder, exist_ok=True)
|
|
280
|
+
|
|
281
|
+
# Save dynamic profile indicators inside local data markers structures
|
|
282
|
+
save_session(session["auth_token"], session["user_email"], team_code, project_name)
|
|
283
|
+
|
|
284
|
+
local_marker_path = os.path.join(target_clone_folder, '.candles')
|
|
285
|
+
os.makedirs(local_marker_path, exist_ok=True)
|
|
286
|
+
with open(os.path.join(local_marker_path, 'project_context.json'), 'w') as f:
|
|
287
|
+
json.dump({"team_code": team_code, "project_name": project_name, "initialized_at": target_clone_folder}, f, indent=4)
|
|
288
|
+
|
|
289
|
+
click.echo(click.style(f"\nExecuting command utility pipeline: cn-{project_name.lower()}-clone...", fg="cyan"))
|
|
290
|
+
files_res = requests.get(f"{BACKEND_URL}/files?team_code={team_code}", headers=get_headers(session))
|
|
291
|
+
|
|
292
|
+
if files_res.status_code == 200:
|
|
293
|
+
files = files_res.json().get("files", [])
|
|
294
|
+
for file_data in files:
|
|
295
|
+
path = file_data["path"]
|
|
296
|
+
content = base64.b64decode(file_data["content"])
|
|
297
|
+
full_path = os.path.join(target_clone_folder, path)
|
|
298
|
+
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
|
299
|
+
with open(full_path, 'wb') as f:
|
|
300
|
+
f.write(content)
|
|
301
|
+
|
|
302
|
+
click.echo(click.style(f"\n[Success] Your project '{project_name}' has been successfully cloned to the local filesystem wrapper context!", fg="green", bold=True))
|
|
303
|
+
click.echo(click.style(f"To activate dynamic updates, navigate using: 'cd {project_name}' and run 'cn link'. Files will dynamically update to the workspace of candles.", fg="yellow"))
|
|
304
|
+
|
|
305
|
+
except Exception as e:
|
|
306
|
+
click.echo(click.style(f"[Error] Repository initialization pipeline failed: {e}", fg="red"))
|
|
307
|
+
|
|
308
|
+
@cli.command()
|
|
309
|
+
@click.argument('file_path', required=False)
|
|
310
|
+
def drop(file_path):
|
|
311
|
+
"""Delete a file locally and from the remote server workspace explorer."""
|
|
312
|
+
session = load_session()
|
|
313
|
+
local_ctx = load_local_context()
|
|
314
|
+
|
|
315
|
+
if not session:
|
|
316
|
+
click.echo(click.style("[Warning] No active profile found. Please run 'cn login' first.", fg="yellow"))
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
team_code = local_ctx["team_code"] if local_ctx else session.get("team_code")
|
|
320
|
+
if not team_code:
|
|
321
|
+
click.echo(click.style("[Error] Directory not linked. Run 'cn init' first.", fg="red"))
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
if not file_path:
|
|
325
|
+
file_path = click.prompt("Enter the relative file path to delete/drop")
|
|
326
|
+
|
|
327
|
+
if not click.confirm(f"[Warning] Are you sure you want to drop '{file_path}' from local and remote workspaces?"):
|
|
328
|
+
click.echo(click.style("[Aborted] Drop action aborted.", fg="yellow"))
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
local_file = os.path.join(os.getcwd(), file_path)
|
|
332
|
+
if os.path.exists(local_file):
|
|
333
|
+
try:
|
|
334
|
+
if os.path.isdir(local_file):
|
|
335
|
+
import shutil
|
|
336
|
+
shutil.rmtree(local_file)
|
|
337
|
+
else:
|
|
338
|
+
os.remove(local_file)
|
|
339
|
+
click.echo(click.style(f"[Success] Deleted local file: {file_path}", fg="green"))
|
|
340
|
+
except Exception as e:
|
|
341
|
+
click.echo(click.style(f"[Error] Failed to delete local file: {e}", fg="red"))
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
response = requests.post(
|
|
345
|
+
f"{BACKEND_URL}/drop",
|
|
346
|
+
json={"team_code": team_code, "path": file_path.replace('\\', '/')},
|
|
347
|
+
headers=get_headers(session)
|
|
348
|
+
)
|
|
349
|
+
if response.status_code == 200:
|
|
350
|
+
click.echo(click.style(f"[Success] Successfully dropped '{file_path}' from Candles Cloud Explorer.", fg="green", bold=True))
|
|
351
|
+
else:
|
|
352
|
+
click.echo(click.style(f"[Error] Failed to drop '{file_path}' on server: {response.text}", fg="red"))
|
|
353
|
+
except Exception as e:
|
|
354
|
+
click.echo(click.style("[Error] Remote drop connection failed: " + str(e), fg="red"))
|
|
355
|
+
|
|
356
|
+
@cli.command()
|
|
357
|
+
def status():
|
|
358
|
+
"""Check the status of your current local terminal workspace profile."""
|
|
359
|
+
session = load_session()
|
|
360
|
+
local_ctx = load_local_context()
|
|
361
|
+
|
|
362
|
+
if not session:
|
|
363
|
+
click.echo(click.style("[Warning] No active profile found. Run 'cn login' first.", fg="yellow"))
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
click.echo(click.style("[Status] Candles Connection Status:", fg="cyan", bold=True))
|
|
367
|
+
click.echo(f" Logged In As: {session['user_email']}")
|
|
368
|
+
click.echo(f" Device Name: {session['device_name']}")
|
|
369
|
+
if session.get("team_code"):
|
|
370
|
+
click.echo(f" Active Project (Global): {session.get('project_name', 'Unknown')} ({session.get('team_code')})")
|
|
371
|
+
if local_ctx:
|
|
372
|
+
click.echo(click.style(f"[Success] Linked Folder (Local): {local_ctx.get('project_name', 'Unknown')} ({local_ctx.get('team_code')})", fg="green"))
|
|
373
|
+
else:
|
|
374
|
+
click.echo(click.style("[Warning] Current folder is not linked locally. Run 'cn init'.", fg="yellow"))
|
|
375
|
+
|
|
376
|
+
@cli.command()
|
|
377
|
+
def logout():
|
|
378
|
+
"""Clear local session storage files to log out safely."""
|
|
379
|
+
clear_session()
|
|
380
|
+
click.echo(click.style("[Success] Logged out of Candles. Local configuration purged.", fg="yellow", bold=True))
|
|
381
|
+
|
|
382
|
+
if __name__ == '__main__':
|
|
383
|
+
cli()
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# 📄 Location: d:/Ptojects/TeamBridge/cli/candles/tracker.py
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import json
|
|
5
|
+
import base64
|
|
6
|
+
import requests
|
|
7
|
+
import threading
|
|
8
|
+
from watchdog.observers import Observer
|
|
9
|
+
from watchdog.events import FileSystemEventHandler
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import socketio
|
|
13
|
+
except ImportError:
|
|
14
|
+
socketio = None
|
|
15
|
+
|
|
16
|
+
BACKEND_URL = "https://candels.onrender.com/api/cli"
|
|
17
|
+
|
|
18
|
+
def is_binary_file(filepath):
|
|
19
|
+
"""Check if a file is binary by reading the first 1024 bytes and searching for null byte or high non-text byte ratio."""
|
|
20
|
+
try:
|
|
21
|
+
if not os.path.exists(filepath) or os.path.isdir(filepath):
|
|
22
|
+
return False
|
|
23
|
+
with open(filepath, 'rb') as f:
|
|
24
|
+
chunk = f.read(1024)
|
|
25
|
+
if b'\x00' in chunk:
|
|
26
|
+
return True
|
|
27
|
+
non_printable = sum(1 for byte in chunk if byte < 32 and byte not in (9, 10, 13))
|
|
28
|
+
if chunk and (non_printable / len(chunk)) > 0.3:
|
|
29
|
+
return True
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
class ProjectActivityHandler(FileSystemEventHandler):
|
|
35
|
+
"""Listens natively to file system events and aggregates activity + syncs changes to server."""
|
|
36
|
+
def __init__(self, team_code, auth_token, settings=None, document_paths=None):
|
|
37
|
+
self.team_code = team_code
|
|
38
|
+
self.auth_token = auth_token
|
|
39
|
+
self.document_paths = document_paths or set()
|
|
40
|
+
self.pending_changes = {}
|
|
41
|
+
self.last_event_time = 0
|
|
42
|
+
self.lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
self.max_file_size_bytes = 2 * 1024 * 1024
|
|
45
|
+
self.allowed_exts = None
|
|
46
|
+
self.ignored_folders = ['.git', 'node_modules', '__pycache__', '.candles', 'venv', 'env']
|
|
47
|
+
|
|
48
|
+
if settings:
|
|
49
|
+
if settings.get("max_file_size_mb") is not None:
|
|
50
|
+
self.max_file_size_bytes = int(settings["max_file_size_mb"] * 1024 * 1024)
|
|
51
|
+
if settings.get("allowed_extensions"):
|
|
52
|
+
self.allowed_exts = {ext.strip().lower() for ext in settings["allowed_extensions"].split(",") if ext.strip()}
|
|
53
|
+
if settings.get("ignored_folders"):
|
|
54
|
+
self.ignored_folders = [f.strip() for f in settings["ignored_folders"].split(",") if f.strip()]
|
|
55
|
+
|
|
56
|
+
self.sio = None
|
|
57
|
+
if socketio:
|
|
58
|
+
try:
|
|
59
|
+
self.sio = socketio.Client()
|
|
60
|
+
@self.sio.on('cli_stream_response')
|
|
61
|
+
def on_cli_stream_response(data):
|
|
62
|
+
status = data.get('status')
|
|
63
|
+
msg = data.get('message', '')
|
|
64
|
+
if status == 'success':
|
|
65
|
+
print(f"[WebSocket Sync] Success: {msg}")
|
|
66
|
+
else:
|
|
67
|
+
print(f"[WebSocket Sync Error] {msg}")
|
|
68
|
+
except Exception as e:
|
|
69
|
+
print(f"[Warning] Failed to initialize Socket.IO client: {e}")
|
|
70
|
+
self.sio = None
|
|
71
|
+
|
|
72
|
+
def start_worker(self):
|
|
73
|
+
if self.sio:
|
|
74
|
+
try:
|
|
75
|
+
# 🛠️ SAFE FIX: Ensure it extracts the root domain properly even if trailing slashes vary
|
|
76
|
+
if '/api' in BACKEND_URL:
|
|
77
|
+
backend_root = BACKEND_URL.rsplit('/api', 1)[0]
|
|
78
|
+
else:
|
|
79
|
+
backend_root = BACKEND_URL.replace('/cli', '')
|
|
80
|
+
|
|
81
|
+
self.sio.connect(backend_root)
|
|
82
|
+
print(f"[WebSocket] Connected to real-time sync engine at {backend_root}")
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f"[WebSocket Warning] Connection failed: {e}. Falling back to HTTP batch uploads.")
|
|
85
|
+
self.sio = None
|
|
86
|
+
|
|
87
|
+
# Thread 1: Real-time quick debounce stream worker
|
|
88
|
+
threading.Thread(target=self.worker_loop, daemon=True).start()
|
|
89
|
+
|
|
90
|
+
# Thread 2: Periodic absolute check routine loop running every 5 minutes and 1 second precisely
|
|
91
|
+
threading.Thread(target=self.five_minute_ticker_loop, daemon=True).start()
|
|
92
|
+
|
|
93
|
+
def worker_loop(self):
|
|
94
|
+
while True:
|
|
95
|
+
time.sleep(0.5)
|
|
96
|
+
self.flush_payloads()
|
|
97
|
+
|
|
98
|
+
def five_minute_ticker_loop(self):
|
|
99
|
+
"""Force system tree checks alignment every 5 minutes and 1 second (301s total) precisely."""
|
|
100
|
+
while True:
|
|
101
|
+
time.sleep(301)
|
|
102
|
+
print("\n[Clock Update Engine] Standard 5-minute synchronization interval tick running updates checklist indices...")
|
|
103
|
+
self.force_full_directory_sync()
|
|
104
|
+
|
|
105
|
+
def force_full_directory_sync(self):
|
|
106
|
+
"""Walks workspace tree structural components queuing all non-ignored updates changes."""
|
|
107
|
+
for root, dirs, files in os.walk(os.getcwd()):
|
|
108
|
+
dirs[:] = [d for d in dirs if d not in self.ignored_folders]
|
|
109
|
+
for file in files:
|
|
110
|
+
full_path = os.path.join(root, file)
|
|
111
|
+
rel_path = os.path.relpath(full_path, os.getcwd())
|
|
112
|
+
with self.lock:
|
|
113
|
+
self.pending_changes[rel_path] = {
|
|
114
|
+
"absolute_path": full_path,
|
|
115
|
+
"timestamp": time.time(),
|
|
116
|
+
"deleted": False
|
|
117
|
+
}
|
|
118
|
+
self.flush_payloads()
|
|
119
|
+
|
|
120
|
+
def flush_payloads(self):
|
|
121
|
+
batch_payload = []
|
|
122
|
+
|
|
123
|
+
with self.lock:
|
|
124
|
+
if not self.pending_changes:
|
|
125
|
+
return
|
|
126
|
+
# Debounce filter validation check logic
|
|
127
|
+
if time.time() - self.last_event_time < 1.5:
|
|
128
|
+
# If an event was captured very recently, let the next loop pass clean
|
|
129
|
+
return
|
|
130
|
+
changes = list(self.pending_changes.items())
|
|
131
|
+
self.pending_changes.clear()
|
|
132
|
+
|
|
133
|
+
for rel_path, info in changes:
|
|
134
|
+
if info["deleted"]:
|
|
135
|
+
batch_payload.append({
|
|
136
|
+
"path": rel_path.replace('\\', '/'),
|
|
137
|
+
"deleted": True
|
|
138
|
+
})
|
|
139
|
+
else:
|
|
140
|
+
try:
|
|
141
|
+
if not os.path.exists(info["absolute_path"]):
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
file_size = os.path.getsize(info["absolute_path"])
|
|
145
|
+
if file_size > self.max_file_size_bytes:
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
if is_binary_file(info["absolute_path"]):
|
|
149
|
+
ext = os.path.splitext(rel_path)[1].lower()
|
|
150
|
+
if ext not in ['.docx', '.doc', '.pdf']:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
with open(info["absolute_path"], 'rb') as f:
|
|
154
|
+
content_b64 = base64.b64encode(f.read()).decode('utf-8')
|
|
155
|
+
|
|
156
|
+
batch_payload.append({
|
|
157
|
+
"path": rel_path.replace('\\', '/'),
|
|
158
|
+
"content": content_b64,
|
|
159
|
+
"deleted": False
|
|
160
|
+
})
|
|
161
|
+
except Exception as e:
|
|
162
|
+
print(f"[Sync Error] Could not read file {rel_path} for batch: {e}")
|
|
163
|
+
|
|
164
|
+
if not batch_payload:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
if self.sio and self.sio.connected:
|
|
168
|
+
try:
|
|
169
|
+
payload = {
|
|
170
|
+
"auth_token": self.auth_token,
|
|
171
|
+
"team_code": self.team_code,
|
|
172
|
+
"files": batch_payload
|
|
173
|
+
}
|
|
174
|
+
self.sio.emit('cli_file_stream', payload)
|
|
175
|
+
print(f"[Dynamic Update Hub] Streamed batch of {len(batch_payload)} actions via WebSocket.")
|
|
176
|
+
except Exception:
|
|
177
|
+
self.send_http_batch(batch_payload)
|
|
178
|
+
else:
|
|
179
|
+
self.send_http_batch(batch_payload)
|
|
180
|
+
|
|
181
|
+
def send_http_batch(self, batch_payload):
|
|
182
|
+
try:
|
|
183
|
+
headers = {"Authorization": f"Bearer {self.auth_token}"}
|
|
184
|
+
payload = {
|
|
185
|
+
"team_code": self.team_code,
|
|
186
|
+
"files": batch_payload
|
|
187
|
+
}
|
|
188
|
+
response = requests.post(
|
|
189
|
+
f"{BACKEND_URL}/upload-batch",
|
|
190
|
+
json=payload,
|
|
191
|
+
headers=headers,
|
|
192
|
+
timeout=15
|
|
193
|
+
)
|
|
194
|
+
if response.status_code == 200:
|
|
195
|
+
print(f"[Dynamic Update Hub] Synchronized batch update payload changes cleanly to cloud repositories context layer.")
|
|
196
|
+
except Exception as e:
|
|
197
|
+
print(f"[Sync Error HTTP] Consolidated batch request failed: {e}")
|
|
198
|
+
|
|
199
|
+
def process_event(self, event, is_deletion=False):
|
|
200
|
+
if event.is_directory:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
canonical_path = os.path.realpath(event.src_path)
|
|
205
|
+
except Exception:
|
|
206
|
+
canonical_path = os.path.abspath(event.src_path)
|
|
207
|
+
|
|
208
|
+
is_registered_doc = False
|
|
209
|
+
registered_doc_path = None
|
|
210
|
+
for doc_path in self.document_paths:
|
|
211
|
+
if os.path.normcase(os.path.realpath(canonical_path)) == os.path.normcase(os.path.realpath(doc_path)):
|
|
212
|
+
is_registered_doc = True
|
|
213
|
+
registered_doc_path = doc_path
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
if not is_registered_doc:
|
|
217
|
+
cwd_path = os.path.realpath(os.getcwd())
|
|
218
|
+
try:
|
|
219
|
+
if os.path.commonpath([cwd_path, canonical_path]) != cwd_path:
|
|
220
|
+
return
|
|
221
|
+
except ValueError:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
relative_path = os.path.relpath(canonical_path, cwd_path)
|
|
225
|
+
else:
|
|
226
|
+
relative_path = registered_doc_path
|
|
227
|
+
|
|
228
|
+
path_parts = relative_path.replace('\\', '/').split('/')
|
|
229
|
+
if any(ignored in path_parts for ignored in self.ignored_folders):
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
if not is_deletion:
|
|
233
|
+
ext = os.path.splitext(canonical_path)[1].lower()
|
|
234
|
+
if self.allowed_exts and ext not in self.allowed_exts:
|
|
235
|
+
if ext not in ['.docx', '.doc', '.pdf']:
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
file_size = os.path.getsize(canonical_path)
|
|
240
|
+
if file_size > self.max_file_size_bytes:
|
|
241
|
+
return
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
with self.lock:
|
|
246
|
+
self.pending_changes[relative_path] = {
|
|
247
|
+
"absolute_path": canonical_path,
|
|
248
|
+
"timestamp": time.time(),
|
|
249
|
+
"deleted": is_deletion
|
|
250
|
+
}
|
|
251
|
+
self.last_event_time = time.time()
|
|
252
|
+
|
|
253
|
+
def on_modified(self, event):
|
|
254
|
+
self.process_event(event, is_deletion=False)
|
|
255
|
+
|
|
256
|
+
def on_created(self, event):
|
|
257
|
+
self.process_event(event, is_deletion=False)
|
|
258
|
+
|
|
259
|
+
def on_deleted(self, event):
|
|
260
|
+
self.process_event(event, is_deletion=True)
|
|
261
|
+
|
|
262
|
+
def start_workspace_monitoring(team_code, auth_token):
|
|
263
|
+
"""Starts the background thread worker watching the current directory and registered documents."""
|
|
264
|
+
path_to_watch = os.getcwd()
|
|
265
|
+
settings = {}
|
|
266
|
+
try:
|
|
267
|
+
response = requests.get(f"https://candels.onrender.com/api/workspace/settings?team_code={team_code}", headers={"Authorization": f"Bearer {auth_token}"}, timeout=5)
|
|
268
|
+
if response.status_code == 200:
|
|
269
|
+
settings = response.json().get("settings", {})
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
document_paths = set()
|
|
274
|
+
try:
|
|
275
|
+
response = requests.get(
|
|
276
|
+
f"http://127.0.0.1:5000/api/workspace/documents?team_code={team_code}",
|
|
277
|
+
headers={"Authorization": f"Bearer {auth_token}"},
|
|
278
|
+
timeout=5
|
|
279
|
+
)
|
|
280
|
+
if response.status_code == 200:
|
|
281
|
+
docs = response.json().get("documents", [])
|
|
282
|
+
for doc in docs:
|
|
283
|
+
file_path = doc.get("file_path")
|
|
284
|
+
if file_path:
|
|
285
|
+
document_paths.add(os.path.abspath(file_path))
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
event_handler = ProjectActivityHandler(team_code, auth_token, settings, document_paths)
|
|
290
|
+
event_handler.start_worker()
|
|
291
|
+
|
|
292
|
+
observer = Observer()
|
|
293
|
+
observer.schedule(event_handler, path=path_to_watch, recursive=True)
|
|
294
|
+
|
|
295
|
+
watched_dirs = {os.path.realpath(path_to_watch)}
|
|
296
|
+
for doc_path in document_paths:
|
|
297
|
+
dir_to_watch = os.path.dirname(doc_path)
|
|
298
|
+
if os.path.exists(dir_to_watch) and os.path.realpath(dir_to_watch) not in watched_dirs:
|
|
299
|
+
try:
|
|
300
|
+
observer.schedule(event_handler, path=dir_to_watch, recursive=False)
|
|
301
|
+
watched_dirs.add(os.path.realpath(dir_to_watch))
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
observer.start()
|
|
306
|
+
print(f"[Start] Candles background file-watcher active on: {path_to_watch}")
|
|
307
|
+
print("Press Ctrl+C to terminate the synchronization monitoring session.")
|
|
308
|
+
try:
|
|
309
|
+
while True:
|
|
310
|
+
time.sleep(1)
|
|
311
|
+
except KeyboardInterrupt:
|
|
312
|
+
observer.stop()
|
|
313
|
+
print("\n[Stop] Workspace monitoring suspended cleanly.")
|
|
314
|
+
observer.join()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# 📄 Location: d:/Ptojects/TeamBridge/cli/setup.py
|
|
2
|
+
from setuptools import setup, find_packages
|
|
3
|
+
|
|
4
|
+
setup(
|
|
5
|
+
name="teambridge-candles",
|
|
6
|
+
version="1.0.0",
|
|
7
|
+
packages=find_packages(),
|
|
8
|
+
install_requires=[
|
|
9
|
+
"requests>=2.31.0",
|
|
10
|
+
"click>=8.1.0",
|
|
11
|
+
"watchdog>=3.0.0",
|
|
12
|
+
"python-socketio[client]>=5.11.0",
|
|
13
|
+
"inquirer>=3.1.3" # Added for premium arrow-key and spacebar selections
|
|
14
|
+
],
|
|
15
|
+
entry_points={
|
|
16
|
+
'console_scripts': [
|
|
17
|
+
'candles=candles.main:cli',
|
|
18
|
+
'cn=candles.main:cli',
|
|
19
|
+
'cn-login=candles.main:login_script',
|
|
20
|
+
'cn-live=candles.main:live_script',
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: teambridge-candles
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Requires-Dist: requests>=2.31.0
|
|
5
|
+
Requires-Dist: click>=8.1.0
|
|
6
|
+
Requires-Dist: watchdog>=3.0.0
|
|
7
|
+
Requires-Dist: python-socketio[client]>=5.11.0
|
|
8
|
+
Requires-Dist: inquirer>=3.1.3
|
|
9
|
+
Dynamic: requires-dist
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
setup.py
|
|
2
|
+
candles/__init__.py
|
|
3
|
+
candles/auth.py
|
|
4
|
+
candles/main.py
|
|
5
|
+
candles/tracker.py
|
|
6
|
+
teambridge_candles.egg-info/PKG-INFO
|
|
7
|
+
teambridge_candles.egg-info/SOURCES.txt
|
|
8
|
+
teambridge_candles.egg-info/dependency_links.txt
|
|
9
|
+
teambridge_candles.egg-info/entry_points.txt
|
|
10
|
+
teambridge_candles.egg-info/requires.txt
|
|
11
|
+
teambridge_candles.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
candles
|