socialcli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of socialcli might be problematic. Click here for more details.

Files changed (224) hide show
  1. socialcli-0.3.0.dist-info/METADATA +168 -0
  2. socialcli-0.3.0.dist-info/RECORD +224 -0
  3. socialcli-0.3.0.dist-info/WHEEL +4 -0
  4. socialcli-0.3.0.dist-info/entry_points.txt +3 -0
  5. socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  6. socialctl/__init__.py +0 -0
  7. socialctl/__main__.py +6 -0
  8. socialctl/adapters/__init__.py +6 -0
  9. socialctl/adapters/base.py +68 -0
  10. socialctl/adapters/errores.py +56 -0
  11. socialctl/adapters/facebook.py +180 -0
  12. socialctl/adapters/instagram.py +442 -0
  13. socialctl/adapters/tiktok.py +535 -0
  14. socialctl/adapters/youtube.py +331 -0
  15. socialctl/agent_errors.py +176 -0
  16. socialctl/agent_guide.py +23 -0
  17. socialctl/approval_cli.py +27 -0
  18. socialctl/auth.py +139 -0
  19. socialctl/authflow.py +344 -0
  20. socialctl/brands.py +194 -0
  21. socialctl/capabilities.py +57 -0
  22. socialctl/capabilities.yml +5945 -0
  23. socialctl/cli.py +1766 -0
  24. socialctl/connection_service/__init__.py +0 -0
  25. socialctl/connection_service/__main__.py +33 -0
  26. socialctl/connection_service/app.py +496 -0
  27. socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
  28. socialctl/connection_service/assets/connection.css +75 -0
  29. socialctl/connection_service/assets/facebook.webp +0 -0
  30. socialctl/connection_service/assets/instagram.svg +194 -0
  31. socialctl/connection_service/assets/socialcli-logo.png +0 -0
  32. socialctl/connection_service/assets/tiktok.png +0 -0
  33. socialctl/connection_service/assets/youtube.svg +1 -0
  34. socialctl/connection_service/landing.py +146 -0
  35. socialctl/connection_service/legacy.py +48 -0
  36. socialctl/connection_service/peer.py +13 -0
  37. socialctl/connection_service/providers.py +324 -0
  38. socialctl/connection_service/settings.py +76 -0
  39. socialctl/connection_service/store.py +124 -0
  40. socialctl/connections/__init__.py +0 -0
  41. socialctl/connections/account_cli.py +438 -0
  42. socialctl/connections/cli.py +379 -0
  43. socialctl/connections/client.py +126 -0
  44. socialctl/connections/credential_cli.py +224 -0
  45. socialctl/connections/credential_store.py +49 -0
  46. socialctl/connections/file_store.py +193 -0
  47. socialctl/connections/keychain.py +78 -0
  48. socialctl/connections/meta_user.py +81 -0
  49. socialctl/connections/rotation_cli.py +116 -0
  50. socialctl/connections/saas_client.py +91 -0
  51. socialctl/connections/temporary_media.py +62 -0
  52. socialctl/delivery_options.py +70 -0
  53. socialctl/engagement.py +200 -0
  54. socialctl/engagement_cli.py +50 -0
  55. socialctl/executor.py +135 -0
  56. socialctl/formatter.py +291 -0
  57. socialctl/hosted_media.py +152 -0
  58. socialctl/identity.py +200 -0
  59. socialctl/inbox.py +298 -0
  60. socialctl/inbox_cli.py +46 -0
  61. socialctl/inventory.py +155 -0
  62. socialctl/inventory_cli.py +171 -0
  63. socialctl/inventory_sources.py +101 -0
  64. socialctl/management/__init__.py +1 -0
  65. socialctl/management/batch_cli.py +115 -0
  66. socialctl/management/batches.py +325 -0
  67. socialctl/management/changes.py +485 -0
  68. socialctl/management/cli.py +227 -0
  69. socialctl/management/comments_cli.py +352 -0
  70. socialctl/management/community_batch_cli.py +63 -0
  71. socialctl/management/community_batches.py +294 -0
  72. socialctl/management/community_changes.py +370 -0
  73. socialctl/management/community_cli.py +142 -0
  74. socialctl/management/community_schema.py +119 -0
  75. socialctl/management/facebook_groups.py +1118 -0
  76. socialctl/management/groups_cli.py +274 -0
  77. socialctl/management/inbox_readers.py +87 -0
  78. socialctl/management/meta_changes.py +197 -0
  79. socialctl/management/meta_cli.py +342 -0
  80. socialctl/management/meta_client.py +631 -0
  81. socialctl/management/meta_comment_inbox.py +565 -0
  82. socialctl/management/meta_comments.py +587 -0
  83. socialctl/management/meta_errors.py +59 -0
  84. socialctl/management/meta_pagination.py +52 -0
  85. socialctl/management/meta_schema.py +128 -0
  86. socialctl/management/meta_webhooks.py +147 -0
  87. socialctl/management/metadata_parts.py +144 -0
  88. socialctl/management/owned_assets.py +85 -0
  89. socialctl/management/owned_changes.py +470 -0
  90. socialctl/management/owned_cli.py +128 -0
  91. socialctl/management/owned_reorder.py +62 -0
  92. socialctl/management/owned_schema.py +368 -0
  93. socialctl/management/reply_context.py +24 -0
  94. socialctl/management/reply_evidence.py +80 -0
  95. socialctl/management/resource_changes.py +407 -0
  96. socialctl/management/resource_cli.py +136 -0
  97. socialctl/management/snippet_readback.py +26 -0
  98. socialctl/management/stories.py +713 -0
  99. socialctl/management/stories_cli.py +172 -0
  100. socialctl/management/youtube.py +323 -0
  101. socialctl/management/youtube_community.py +326 -0
  102. socialctl/management/youtube_metadata.py +122 -0
  103. socialctl/management/youtube_owned.py +259 -0
  104. socialctl/management/youtube_resources.py +305 -0
  105. socialctl/media.py +269 -0
  106. socialctl/media_cli.py +183 -0
  107. socialctl/media_ingress.py +211 -0
  108. socialctl/media_preflight.py +114 -0
  109. socialctl/media_registry.py +301 -0
  110. socialctl/media_requirements.py +52 -0
  111. socialctl/metricas/__init__.py +6 -0
  112. socialctl/metricas/almacen.py +133 -0
  113. socialctl/metricas/analytics_changes.py +295 -0
  114. socialctl/metricas/analytics_cli.py +191 -0
  115. socialctl/metricas/analytics_client.py +304 -0
  116. socialctl/metricas/analytics_ingest.py +260 -0
  117. socialctl/metricas/analytics_schema.py +174 -0
  118. socialctl/metricas/base.py +147 -0
  119. socialctl/metricas/facebook.py +418 -0
  120. socialctl/metricas/insights.py +227 -0
  121. socialctl/metricas/instagram.py +133 -0
  122. socialctl/metricas/modelos.py +87 -0
  123. socialctl/metricas/piezas.py +134 -0
  124. socialctl/metricas/reach.py +94 -0
  125. socialctl/metricas/reporting_client.py +112 -0
  126. socialctl/metricas/resumen.py +251 -0
  127. socialctl/metricas/slugs.py +89 -0
  128. socialctl/metricas/tiktok.py +185 -0
  129. socialctl/metricas/youtube.py +403 -0
  130. socialctl/migration_cli.py +60 -0
  131. socialctl/migration_files.py +68 -0
  132. socialctl/models.py +150 -0
  133. socialctl/native_schedule/__init__.py +13 -0
  134. socialctl/native_schedule/api_delivery.py +15 -0
  135. socialctl/native_schedule/api_reconcile.py +40 -0
  136. socialctl/native_schedule/approval.py +160 -0
  137. socialctl/native_schedule/cadence.py +73 -0
  138. socialctl/native_schedule/cli.py +510 -0
  139. socialctl/native_schedule/dispatcher.py +120 -0
  140. socialctl/native_schedule/facebook.py +112 -0
  141. socialctl/native_schedule/facebook_reels.py +140 -0
  142. socialctl/native_schedule/first_comment.py +181 -0
  143. socialctl/native_schedule/handoff.py +193 -0
  144. socialctl/native_schedule/instagram_delivery.py +224 -0
  145. socialctl/native_schedule/instagram_runtime.py +66 -0
  146. socialctl/native_schedule/inventory.py +70 -0
  147. socialctl/native_schedule/migration.py +310 -0
  148. socialctl/native_schedule/models.py +103 -0
  149. socialctl/native_schedule/reconcile.py +83 -0
  150. socialctl/native_schedule/remote_data.py +177 -0
  151. socialctl/native_schedule/report.py +151 -0
  152. socialctl/native_schedule/store.py +560 -0
  153. socialctl/native_schedule/verification.py +32 -0
  154. socialctl/native_schedule/youtube.py +339 -0
  155. socialctl/operation_output.py +87 -0
  156. socialctl/plantillas/accounts.yml +33 -0
  157. socialctl/plantillas/brand.md +95 -0
  158. socialctl/plantillas/estrategia.md +64 -0
  159. socialctl/postfile.py +240 -0
  160. socialctl/preflight.py +73 -0
  161. socialctl/publication_policy.py +34 -0
  162. socialctl/publication_status.py +75 -0
  163. socialctl/publication_steps.py +398 -0
  164. socialctl/publish_output.py +96 -0
  165. socialctl/publisher.py +445 -0
  166. socialctl/queue_migration.py +302 -0
  167. socialctl/read_budget.py +224 -0
  168. socialctl/read_reports.py +91 -0
  169. socialctl/rutas.py +103 -0
  170. socialctl/saas/__init__.py +1 -0
  171. socialctl/saas/access.py +75 -0
  172. socialctl/saas/auth.py +213 -0
  173. socialctl/saas/backup.py +124 -0
  174. socialctl/saas/billing.py +327 -0
  175. socialctl/saas/billing_enrollment.py +1237 -0
  176. socialctl/saas/billing_enrollment_schema.py +519 -0
  177. socialctl/saas/billing_inventory.py +775 -0
  178. socialctl/saas/billing_reconcile.py +127 -0
  179. socialctl/saas/billing_routes.py +27 -0
  180. socialctl/saas/budget.py +66 -0
  181. socialctl/saas/connections.py +374 -0
  182. socialctl/saas/consent.py +52 -0
  183. socialctl/saas/lifecycle.py +341 -0
  184. socialctl/saas/mail.py +89 -0
  185. socialctl/saas/maintenance.py +68 -0
  186. socialctl/saas/meta_user.py +298 -0
  187. socialctl/saas/meta_webhooks.py +358 -0
  188. socialctl/saas/migration.py +107 -0
  189. socialctl/saas/models.py +8 -0
  190. socialctl/saas/operations.py +102 -0
  191. socialctl/saas/pages.py +126 -0
  192. socialctl/saas/pairing.py +158 -0
  193. socialctl/saas/permissions.py +57 -0
  194. socialctl/saas/reminders.py +128 -0
  195. socialctl/saas/renewal.py +74 -0
  196. socialctl/saas/restore_guard.py +229 -0
  197. socialctl/saas/revocation.py +97 -0
  198. socialctl/saas/rotation.py +114 -0
  199. socialctl/saas/routes.py +221 -0
  200. socialctl/saas/settings.py +34 -0
  201. socialctl/saas/store.py +106 -0
  202. socialctl/saas/temporary_media.py +245 -0
  203. socialctl/saas/webhooks.py +76 -0
  204. socialctl/schedule_remote.py +262 -0
  205. socialctl/schedule_sqlite.py +44 -0
  206. socialctl/scheduler.py +581 -0
  207. socialctl/scheduler_launchd.py +319 -0
  208. socialctl/scheduler_systemd.py +167 -0
  209. socialctl/source_links.py +119 -0
  210. socialctl/ui/__init__.py +1 -0
  211. socialctl/ui/assets/agent-manual.md +850 -0
  212. socialctl/ui/assets/agents.md +27 -0
  213. socialctl/ui/assets/components.css +20 -0
  214. socialctl/ui/assets/connection-layout.css +53 -0
  215. socialctl/ui/assets/otp.template.html +10 -0
  216. socialctl/ui/assets/tokens.css +1 -0
  217. socialctl/ui/assets/trial.template.html +11 -0
  218. socialctl/ui/assets/web-layout.css +38 -0
  219. socialctl/ui/email/otp.html +10 -0
  220. socialctl/ui/email/trial.html +11 -0
  221. socialctl/upload_consent.py +13 -0
  222. socialctl/workspace.py +8 -0
  223. socialctl/youtube_data.py +471 -0
  224. socialctl/youtube_data_service.py +225 -0
@@ -0,0 +1,319 @@
1
+ """Workspace-owned macOS queue agents; unknown supervisor evidence fails closed."""
2
+ import hashlib
3
+ import os
4
+ from pathlib import Path
5
+ import plistlib
6
+ import re
7
+ import shutil
8
+ import stat
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import time
13
+
14
+ from socialctl.scheduler_systemd import SupervisorError
15
+
16
+
17
+ def unit_name(brand: str, root: Path) -> str:
18
+ identity = str(Path(root).resolve()) + '\0' + brand
19
+ return 'com.socialcli.' + hashlib.sha256(identity.encode()).hexdigest()[:20] + '.run-due'
20
+
21
+
22
+ def _plan(brand, root):
23
+ from socialctl.rutas import validar_componente_de_ruta
24
+ root = Path(root).resolve()
25
+ selected = validar_componente_de_ruta(root, brand, SupervisorError, 'brand name')
26
+ if not root.is_dir() or not (selected / 'brand.md').is_file():
27
+ raise SupervisorError('Select an existing brand and its explicit workspace root.')
28
+ # Preserve a venv interpreter symlink, rather than resolving to base Python.
29
+ argv = [str(Path(sys.executable).absolute()), '-m', 'socialctl', 'run-due',
30
+ '--brand', brand, '--root', str(root)]
31
+ if any(any(ord(c) < 32 or ord(c) == 127 for c in arg) for arg in argv):
32
+ raise SupervisorError('Scheduler paths and names must not contain control characters.')
33
+ name = unit_name(brand, root)
34
+ directory = Path.home() / 'Library/LaunchAgents'
35
+ log_dir = selected / '.socialctl'
36
+ payload = {'Label': name, 'ProgramArguments': argv, 'WorkingDirectory': str(root),
37
+ 'StartInterval': 60, 'RunAtLoad': True, 'Umask': 0o077,
38
+ 'StandardOutPath': str(log_dir / 'run-due.log'),
39
+ 'StandardErrorPath': str(log_dir / 'run-due.error.log'), 'ProcessType': 'Background'}
40
+ return directory / (name + '.plist'), payload, plistlib.dumps(payload, sort_keys=False)
41
+
42
+
43
+ def _run(args, *, check=True):
44
+ executable = shutil.which('launchctl')
45
+ if not executable:
46
+ raise SupervisorError('No launchd supervisor is available. Nothing was verified or changed.')
47
+ try:
48
+ result = subprocess.run([executable, *args], capture_output=True, text=True,
49
+ timeout=15, check=False)
50
+ except (OSError, subprocess.TimeoutExpired):
51
+ raise SupervisorError('Could not contact the macOS user supervisor; inspect launchctl before retrying.') from None
52
+ if check and result.returncode:
53
+ raise SupervisorError('The macOS user supervisor rejected the operation; inspect launchctl before retrying.')
54
+ return result
55
+
56
+
57
+ def _domain():
58
+ domain = f'gui/{os.getuid()}'
59
+ _run(['print', domain])
60
+ return domain
61
+
62
+
63
+ def _job(domain, name):
64
+ result = _run(['print', domain + '/' + name], check=False)
65
+ if result.returncode == 0:
66
+ return result.stdout
67
+ # A successful domain probe plus the specific missing-service diagnostic is
68
+ # necessary: arbitrary command failures are not evidence of absence.
69
+ if (result.returncode == 113 and
70
+ f'Could not find service "{name}" in domain' in result.stderr):
71
+ return None
72
+ raise SupervisorError('Could not verify whether the macOS scheduler is registered; nothing was replaced or removed.')
73
+
74
+
75
+ def _safe_parents(path):
76
+ if not path.is_absolute() or any(parent.is_symlink() for parent in path.parents):
77
+ raise SupervisorError('Refusing a symlinked supervisor configuration directory.')
78
+
79
+
80
+ def _configuration(path, expected):
81
+ _safe_parents(path)
82
+ if not path.exists() and not path.is_symlink():
83
+ return False
84
+ # Read without following the final link and validate the opened inode.
85
+ if path.is_symlink() or not path.is_file():
86
+ raise SupervisorError('Existing scheduler configuration is unsafe; review it manually.')
87
+ with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW), 'rb') as stream:
88
+ info = os.fstat(stream.fileno())
89
+ if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
90
+ or stat.S_IMODE(info.st_mode) != 0o600 or stream.read() != expected):
91
+ raise SupervisorError('Existing scheduler configuration differs or is not private and owned; nothing was replaced or removed.')
92
+ return True
93
+
94
+
95
+ def _mkdir(directory):
96
+ _safe_parents(directory / '.scheduler-check')
97
+ missing = []
98
+ while not directory.exists():
99
+ missing.append(directory)
100
+ directory = directory.parent
101
+ for directory in reversed(missing):
102
+ directory.mkdir(mode=0o700)
103
+
104
+
105
+ def _save_new(path, content):
106
+ # Link a complete private file into place without ever replacing a target,
107
+ # including one created between the preflight check and this operation.
108
+ with tempfile.NamedTemporaryFile(dir=path.parent, prefix='.' + path.name, delete=False) as stream:
109
+ temporary = Path(stream.name)
110
+ try:
111
+ os.fchmod(stream.fileno(), 0o600)
112
+ stream.write(content)
113
+ stream.flush()
114
+ os.fsync(stream.fileno())
115
+ except BaseException:
116
+ temporary.unlink(missing_ok=True)
117
+ raise
118
+ try:
119
+ os.link(temporary, path, follow_symlinks=False)
120
+ finally:
121
+ temporary.unlink(missing_ok=True)
122
+
123
+
124
+ def _fields(output, target):
125
+ """Read only unambiguous direct job fields, never nested environment fields.
126
+
127
+ Apple explicitly documents `print` as diagnostic output, not an API. A
128
+ changed/missing format therefore yields unverified evidence, not success.
129
+ """
130
+ lines = output.strip().splitlines()
131
+ if not lines or lines[0].strip() != target + ' = {' or lines[-1].strip() != '}':
132
+ return {}, []
133
+ fields, argv, depth, arguments = {}, [], 1, False
134
+ for line in lines[1:-1]:
135
+ text = line.strip()
136
+ if text == '}':
137
+ depth -= 1
138
+ if depth < 1:
139
+ return {}, []
140
+ if depth == 1:
141
+ arguments = False
142
+ elif text.endswith(' = {'):
143
+ if depth == 1 and text == 'arguments = {':
144
+ if 'arguments' in fields:
145
+ return {}, []
146
+ fields['arguments'] = True
147
+ arguments = True
148
+ depth += 1
149
+ elif depth == 2 and arguments:
150
+ if not text:
151
+ return {}, []
152
+ argv.append(text)
153
+ elif depth == 1 and ' = ' in text:
154
+ key, value = text.split(' = ', 1)
155
+ if key in fields:
156
+ return {}, []
157
+ fields[key] = value
158
+ return (fields, argv) if depth == 1 else ({}, [])
159
+
160
+
161
+ def _loaded_matches(output, domain, path, payload):
162
+ fields, argv = _fields(output, domain + '/' + payload['Label'])
163
+ expected = {'path': str(path), 'type': 'LaunchAgent',
164
+ 'program': payload['ProgramArguments'][0],
165
+ 'working directory': payload['WorkingDirectory'],
166
+ 'stdout path': payload['StandardOutPath'], 'stderr path': payload['StandardErrorPath'],
167
+ 'run interval': '60 seconds'}
168
+ conflicts = (any(key in fields and fields[key] != value for key, value in expected.items())
169
+ or ('arguments' in fields and argv != payload['ProgramArguments']))
170
+ return (all(fields.get(key) == value for key, value in expected.items())
171
+ and argv == payload['ProgramArguments']
172
+ and fields.get('state') in {'running', 'not running'}), fields, conflicts
173
+
174
+
175
+ def _enabled(domain, name):
176
+ result = _run(['print-disabled', domain], check=False)
177
+ if result.returncode:
178
+ return None
179
+ lines = result.stdout.strip().splitlines()
180
+ if not lines or lines[0].strip() != 'disabled services = {' or lines[-1].strip() != '}':
181
+ return None
182
+ # Current macOS prints enabled/disabled; retain the boolean spelling used
183
+ # by earlier diagnostic fixtures without treating unknowns as on.
184
+ states = r'(true|false|enabled|disabled)'
185
+ entries = []
186
+ for line in lines[1:-1]:
187
+ if not re.fullmatch(r'\s*"[^"\n]+"\s*=>\s*' + states + r'\s*', line):
188
+ return None
189
+ match = re.fullmatch(r'\s*"' + re.escape(name) + r'"\s*=>\s*' + states + r'\s*', line)
190
+ if match:
191
+ entries.append(match.group(1))
192
+ elif '"' + name + '"' in line:
193
+ return None
194
+ return entries[0] in {'false', 'enabled'} if len(entries) == 1 else None
195
+
196
+
197
+ def _legacy_present(brand, path, domain):
198
+ name = f'com.socialctl.{brand.lower()}.run-due'
199
+ legacy_path = path.parent / (name + '.plist')
200
+ return legacy_path.exists() or legacy_path.is_symlink() or _job(domain, name) is not None
201
+
202
+
203
+ def inspect(brand: str, root: Path) -> dict:
204
+ report = {'kind': 'launchd_user', 'unit': unit_name(brand, root),
205
+ 'configuration_matches': False, 'registered': False, 'enabled': None,
206
+ 'active': False, 'survives_logout': False, 'service_failed': None,
207
+ 'last_exit_code': None, 'last_terminating_signal': None,
208
+ 'reason': 'configuration_missing'}
209
+ try:
210
+ path, payload, content = _plan(brand, root)
211
+ if not _configuration(path, content):
212
+ return report
213
+ domain = _domain()
214
+ if _legacy_present(brand, path, domain):
215
+ report['reason'] = 'legacy_job_present'
216
+ return report
217
+ observed = _job(domain, payload['Label'])
218
+ report['registered'] = observed is not None
219
+ if observed is None:
220
+ report['reason'] = 'job_unregistered'
221
+ return report
222
+ matches, fields, conflicts = _loaded_matches(observed, domain, path, payload)
223
+ report['configuration_matches'] = matches
224
+ code, signal = fields.get('last exit code', ''), fields.get('last terminating signal', '')
225
+ if re.fullmatch(r'-?\d+', code):
226
+ report['last_exit_code'] = int(code)
227
+ report['service_failed'] = int(code) != 0
228
+ if re.fullmatch(r'\d+', signal):
229
+ report['last_terminating_signal'] = int(signal)
230
+ if int(signal) != 0:
231
+ report['service_failed'] = True
232
+ elif 'last terminating signal' in fields and report['service_failed'] is not True:
233
+ # Unknown signal text must not inherit success from an older exit.
234
+ report['service_failed'] = None
235
+ if not matches:
236
+ report['reason'] = 'loaded_configuration_mismatch' if conflicts else 'loaded_configuration_unverified'
237
+ return report
238
+ report['active'] = True # Registered interval task; a PID need not run between ticks.
239
+ report['enabled'] = _enabled(domain, payload['Label'])
240
+ report['reason'] = ('last_execution_failed' if report['service_failed'] is True
241
+ else 'verified_now' if report['enabled'] is True and report['service_failed'] is False
242
+ else 'execution_or_enabled_state_unverified')
243
+ except SupervisorError:
244
+ report['reason'] = 'configuration_or_supervisor_unverified'
245
+ except (OSError, ValueError):
246
+ report['reason'] = 'configuration_unreadable'
247
+ return report
248
+
249
+
250
+ def install(brand: str, root: Path) -> dict:
251
+ path, payload, content = _plan(brand, root)
252
+ saved = _configuration(path, content)
253
+ domain = _domain()
254
+ if _legacy_present(brand, path, domain):
255
+ raise SupervisorError('A legacy brand-only scheduler exists. Review and explicitly migrate it first; nothing was replaced. See docs/local-scheduler.md.')
256
+ observed = _job(domain, payload['Label'])
257
+ if observed is not None and (not saved or not _loaded_matches(observed, domain, path, payload)[0]):
258
+ raise SupervisorError('An existing launchd job differs or is unverified; nothing was replaced.')
259
+ # launchd opens the log targets. Do not direct it through caller-supplied links.
260
+ log_dir = Path(payload['StandardOutPath']).parent
261
+ _safe_parents(log_dir / '.scheduler-check')
262
+ for key in ('StandardOutPath', 'StandardErrorPath'):
263
+ log = Path(payload[key])
264
+ if log.is_symlink() or (log.exists() and (not log.is_file() or log.stat().st_uid != os.getuid())):
265
+ raise SupervisorError('The scheduler log destination is unsafe; nothing was installed.')
266
+ _mkdir(path.parent)
267
+ _mkdir(log_dir)
268
+ if not saved:
269
+ _save_new(path, content)
270
+ if not _configuration(path, content):
271
+ raise SupervisorError('The scheduler configuration disappeared during inspection; nothing was loaded.')
272
+ _run(['enable', domain + '/' + payload['Label']])
273
+ if observed is None:
274
+ _run(['bootstrap', domain, str(path)])
275
+ # A new bootstrap can return before a complete diagnostic snapshot exists.
276
+ # Obtain fresh positive proof for a short bounded period; never replay a
277
+ # mutation or reinterpret missing evidence as a successful installation.
278
+ attempts = 5 if observed is None else 1
279
+ deadline = time.monotonic() + 1.0
280
+ retryable = {'job_unregistered', 'loaded_configuration_unverified',
281
+ 'configuration_or_supervisor_unverified', 'execution_or_enabled_state_unverified'}
282
+ for attempt in range(attempts):
283
+ if attempt and time.monotonic() >= deadline:
284
+ break
285
+ if not _configuration(path, content):
286
+ break
287
+ report = inspect(brand, root)
288
+ if not _configuration(path, content):
289
+ break
290
+ if report['service_failed'] is True or report['enabled'] is False:
291
+ break
292
+ if all(report[key] is True for key in ('configuration_matches', 'registered', 'enabled', 'active')):
293
+ return report
294
+ if (report['reason'] not in retryable or attempt + 1 == attempts
295
+ or time.monotonic() + 0.2 >= deadline):
296
+ break
297
+ time.sleep(0.2)
298
+ raise SupervisorError('The scheduler configuration was saved, but a working enabled launchd job was not verified; run schedule-doctor.')
299
+
300
+
301
+ def uninstall(brand: str, root: Path) -> bool:
302
+ path, payload, content = _plan(brand, root)
303
+ saved = _configuration(path, content)
304
+ domain = _domain()
305
+ observed = _job(domain, payload['Label'])
306
+ if observed is None and not saved:
307
+ return False
308
+ if observed is not None:
309
+ if not saved or not _loaded_matches(observed, domain, path, payload)[0]:
310
+ raise SupervisorError('The registered scheduler differs or is unverified; nothing was removed.')
311
+ if not _configuration(path, content):
312
+ raise SupervisorError('The scheduler ownership file disappeared during inspection; nothing was removed.')
313
+ _run(['bootout', domain + '/' + payload['Label']])
314
+ if _job(domain, payload['Label']) is not None:
315
+ raise SupervisorError('The scheduler is still registered; its configuration was preserved.')
316
+ # Recheck before unlinking; never delete an edited/foreign file after bootout.
317
+ if _configuration(path, content):
318
+ path.unlink()
319
+ return True
@@ -0,0 +1,167 @@
1
+ """Customer-owned Linux user timers. No shell, media transfer or policy changes."""
2
+ import hashlib
3
+ import os
4
+ from pathlib import Path
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+
10
+
11
+ class SupervisorError(ValueError):
12
+ pass
13
+
14
+
15
+ def unit_name(brand: str, root: Path) -> str:
16
+ identity = str(Path(root).resolve()) + '\0' + brand
17
+ return 'socialcli-' + hashlib.sha256(identity.encode()).hexdigest()[:20] + '-run-due'
18
+
19
+
20
+ def _quote(value, *, command=False):
21
+ value = str(value)
22
+ if not value or any(ord(c) < 32 or ord(c) == 127 for c in value):
23
+ raise SupervisorError('Scheduler paths and names must not contain control characters')
24
+ value = value.replace('\\', '\\\\').replace('"', '\\"').replace('%', '%%')
25
+ if command:
26
+ value = value.replace('$', '$$')
27
+ return '"' + value + '"'
28
+
29
+
30
+ def render(brand: str, root: Path, *, executable=None) -> tuple[str, str]:
31
+ root = Path(root).resolve()
32
+ executable = Path(executable or sys.executable).absolute()
33
+ name = unit_name(brand,root)
34
+ argv = [str(executable), '-m', 'socialctl', 'run-due', '--brand', brand, '--root', str(root)]
35
+ command = ' '.join(_quote(arg,command=True) for arg in argv)
36
+ # WorkingDirectory consumes the raw value, unlike ExecStart's word parser.
37
+ # Reject suffixes the unit-file parser would strip or join to the next line.
38
+ directory = str(root)
39
+ if directory != directory.rstrip() or directory.endswith('\\'):
40
+ raise SupervisorError('Scheduler workspace must not end with whitespace or a backslash')
41
+ directory = directory.replace('%','%%')
42
+ service = ('# Managed by SocialCli\n[Unit]\nDescription=SocialCli local queue executor\n\n'
43
+ '[Service]\nType=oneshot\nWorkingDirectory=' + directory + '\nExecStart=' + command +
44
+ '\nTimeoutStartSec=0\nUMask=0077\n')
45
+ timer = ('# Managed by SocialCli\n[Unit]\nDescription=SocialCli local queue timer\n\n'
46
+ '[Timer]\nOnActiveSec=5s\nOnUnitInactiveSec=60s\nAccuracySec=1s\nUnit=' + name +
47
+ '.service\n\n[Install]\nWantedBy=timers.target\n')
48
+ return service,timer
49
+
50
+
51
+ def _directory():
52
+ configured = os.environ.get('XDG_CONFIG_HOME','').strip()
53
+ base = Path(configured) if configured else Path.home()/'.config'
54
+ if not base.is_absolute():
55
+ raise SupervisorError('XDG_CONFIG_HOME must be an absolute directory')
56
+ return base/'systemd'/'user'
57
+
58
+
59
+ def _run(tool, args, *, check=True):
60
+ executable = shutil.which(tool)
61
+ if not executable:
62
+ raise SupervisorError(f'{tool} is unavailable; no working Linux user supervisor was verified')
63
+ try:
64
+ result = subprocess.run([executable,*args],capture_output=True,text=True,timeout=15,check=False)
65
+ except (OSError,subprocess.TimeoutExpired) as exc:
66
+ raise SupervisorError('Could not contact the Linux user supervisor; inspect the user service manager') from exc
67
+ if check and result.returncode:
68
+ raise SupervisorError('Linux user supervisor operation failed; inspect systemctl --user status and journalctl --user')
69
+ return result
70
+
71
+
72
+ def _atomic(path, text):
73
+ if path.is_symlink():
74
+ raise SupervisorError('Refusing to replace a symlinked scheduler unit')
75
+ with tempfile.NamedTemporaryFile(mode='w',dir=path.parent,prefix='.'+path.name,delete=False) as stream:
76
+ temporary = Path(stream.name)
77
+ try:
78
+ stream.write(text)
79
+ stream.flush()
80
+ os.fsync(stream.fileno())
81
+ except BaseException:
82
+ temporary.unlink(missing_ok=True)
83
+ raise
84
+ try:
85
+ temporary.chmod(0o600)
86
+ os.replace(temporary,path)
87
+ finally:
88
+ temporary.unlink(missing_ok=True)
89
+
90
+
91
+ def inspect(brand: str, root: Path) -> dict:
92
+ name = unit_name(brand,root)
93
+ result = {'kind':'systemd_user','unit':name, 'configuration_matches':False,
94
+ 'enabled':False,'active':False,'survives_logout':None,'service_failed':None}
95
+ try:
96
+ directory = _directory()
97
+ expected = render(brand,root)
98
+ paths = [directory/(name+suffix) for suffix in ('.service','.timer')]
99
+ if any(path.is_symlink() or not path.is_file() or path.read_text()!=text
100
+ for path,text in zip(paths,expected)):
101
+ return result
102
+ for path in paths:
103
+ observed = _run('systemctl',['--user','show','--property=NeedDaemonReload,DropInPaths,FragmentPath,ActiveState,Result',path.name])
104
+ properties = dict(line.split('=',1) for line in observed.stdout.splitlines() if '=' in line)
105
+ if (properties.get('NeedDaemonReload')!='no' or properties.get('DropInPaths')!=''
106
+ or properties.get('FragmentPath')!=str(path)):
107
+ return result
108
+ if path.suffix == '.service':
109
+ result['service_failed'] = (properties.get('Result') != 'success'
110
+ or properties.get('ActiveState') not in {'active','inactive','activating'})
111
+ result['configuration_matches']=True
112
+ enabled = _run('systemctl',['--user','is-enabled',name+'.timer'],check=False)
113
+ result['enabled']=enabled.returncode==0 and enabled.stdout.strip()=='enabled'
114
+ active = _run('systemctl',['--user','is-active',name+'.timer'],check=False)
115
+ result['active']=active.returncode==0 and active.stdout.strip()=='active'
116
+ linger = _run('loginctl',['show-user',str(os.getuid()),'--property=Linger','--value'],check=False)
117
+ if linger.returncode==0 and linger.stdout.strip() in {'yes','no'}:
118
+ result['survives_logout']=linger.stdout.strip()=='yes'
119
+ except (SupervisorError,OSError,ValueError):
120
+ # Fixed fields only; do not echo manager output or environment variables.
121
+ pass
122
+ return result
123
+
124
+
125
+ def install(brand: str, root: Path) -> dict:
126
+ expected = render(brand,root) # Validate paths before effects.
127
+ _run('systemctl',['--user','show','--property=Version','--value'])
128
+ directory = _directory()
129
+ directory.mkdir(parents=True,exist_ok=True)
130
+ name = unit_name(brand,root)
131
+ for suffix,text in zip(('.service','.timer'),expected):
132
+ _atomic(directory/(name+suffix),text)
133
+ _run('systemctl',['--user','daemon-reload'])
134
+ _run('systemctl',['--user','enable','--now',name+'.timer'])
135
+ report = inspect(brand,root)
136
+ if not all(report[key] for key in ('configuration_matches','enabled','active')):
137
+ raise SupervisorError('Scheduler units were saved, but an enabled active timer was not verified; run schedule-doctor')
138
+ return report
139
+
140
+
141
+ def uninstall(brand: str, root: Path) -> bool:
142
+ directory = _directory()
143
+ name = unit_name(brand,root)
144
+ paths = [directory/(name+suffix) for suffix in ('.timer','.service')]
145
+ has_files = any(path.exists() or path.is_symlink() for path in paths)
146
+ if any(path.is_symlink() for path in paths):
147
+ raise SupervisorError('Refusing to remove a symlinked scheduler unit')
148
+ # A manager can retain an active timer after its files disappear. Missing
149
+ # files alone must never be reported as successful absence.
150
+ _run('systemctl',['--user','show','--property=Version','--value'])
151
+ observed = _run('systemctl',['--user','show','--property=LoadState,ActiveState,UnitFileState',name+'.timer'],check=False)
152
+ properties = dict(line.split('=',1) for line in observed.stdout.splitlines() if '=' in line)
153
+ absent = (properties.get('LoadState') == 'not-found'
154
+ and properties.get('ActiveState') == 'inactive'
155
+ and properties.get('UnitFileState') in {'','disabled','not-found'})
156
+ if not has_files and absent:
157
+ return False
158
+ if properties.get('ActiveState') != 'inactive':
159
+ # Stop first: disable --now can fail before its stop phase when the
160
+ # on-disk configuration is damaged. Never stop the paired service.
161
+ _run('systemctl',['--user','stop',name+'.timer'])
162
+ _run('systemctl',['--user','disable','--now',name+'.timer'])
163
+ # An in-flight approved write may finish. Killing it would create uncertainty.
164
+ for path in paths:
165
+ path.unlink(missing_ok=True)
166
+ _run('systemctl',['--user','daemon-reload'])
167
+ return True
@@ -0,0 +1,119 @@
1
+ """Resolve explicit creator-owned source IDs before the existing approval flow."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ from urllib.parse import parse_qs, urlsplit
8
+
9
+ import yaml
10
+
11
+ from socialctl.models import Platform
12
+
13
+
14
+ class StrictLoader(yaml.SafeLoader):
15
+ pass
16
+
17
+
18
+ def _mapping(loader, node):
19
+ result = {}
20
+ for key_node, value_node in node.value:
21
+ key = loader.construct_object(key_node)
22
+ if not isinstance(key, str) or key in result:
23
+ raise ValueError('source-videos.yml contains duplicate/non-string keys')
24
+ result[key] = loader.construct_object(value_node)
25
+ return result
26
+
27
+
28
+ StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _mapping)
29
+
30
+
31
+ def _entry(brand, slug):
32
+ path = brand.raiz / 'source-videos.yml'
33
+ try:
34
+ with path.open('rb') as handle:
35
+ raw = handle.read(256 * 1024 + 1)
36
+ except FileNotFoundError:
37
+ return None
38
+ except OSError:
39
+ raise ValueError('cannot read source-videos.yml') from None
40
+ try:
41
+ if len(raw) > 256 * 1024:
42
+ raise ValueError('source-videos.yml exceeds 256 KiB')
43
+ data = yaml.load(raw.decode('utf-8'), Loader=StrictLoader)
44
+ if (not isinstance(data, dict) or set(data) != {'version', 'clips'}
45
+ or type(data['version']) is not int or data['version'] != 1
46
+ or not isinstance(data['clips'], dict)):
47
+ raise ValueError('source-videos.yml requires version: 1 and an explicit clips mapping')
48
+ for key, row in data['clips'].items():
49
+ if (not isinstance(key, str) or not key or '/' in key or '\\' in key
50
+ or key in {'.', '..'} or not isinstance(row, dict)
51
+ or not {'video_id'} <= set(row) <= {'video_id', 'series'}
52
+ or not isinstance(row['video_id'], str)
53
+ or not re.fullmatch(r'[A-Za-z0-9_-]{11}', row['video_id'])
54
+ or ('series' in row and (not isinstance(row['series'], str) or not row['series'].strip()))):
55
+ raise ValueError('invalid exact clip mapping: use a quoted 11-character video_id and optional series')
56
+ return data['clips'].get(slug)
57
+ except (yaml.YAMLError, UnicodeError):
58
+ raise ValueError('source-videos.yml is not valid strict YAML') from None
59
+
60
+
61
+ def resolve_source_link(brand, post_slug):
62
+ entry = _entry(brand, post_slug)
63
+ return f"https://www.youtube.com/watch?v={entry['video_id']}" if entry else None
64
+
65
+
66
+ def _digest(slug, entry):
67
+ return hashlib.sha256(json.dumps({'version': 1, 'slug': slug, 'entry': entry},
68
+ sort_keys=True, separators=(',', ':')).encode()).hexdigest()
69
+
70
+
71
+ def _video_links(text):
72
+ for raw in re.findall(r'(?:https?://|www\.)[^\s<>]+', text or ''):
73
+ url = urlsplit(raw if '://' in raw else f'https://{raw}')
74
+ host = (url.hostname or '').casefold()
75
+ parts = url.path.strip('/').split('/')
76
+ if host in {'youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com'}:
77
+ ids = parse_qs(url.query).get('v', []) if url.path == '/watch' else parts[1:2] if parts[0] in {'shorts', 'embed', 'live'} else []
78
+ for ident in ids:
79
+ yield ident
80
+ elif host in {'youtu.be', 'www.youtu.be'}:
81
+ yield parts[0]
82
+
83
+
84
+ def apply_source_mapping(brand, post, *, required=False):
85
+ entry = _entry(brand, post.slug)
86
+ if entry is None:
87
+ if required:
88
+ raise ValueError('source link unresolved: add this exact post slug to source-videos.yml')
89
+ return post
90
+ post = post.model_copy(deep=True)
91
+ ident = entry['video_id']
92
+ url = f'https://www.youtube.com/watch?v={ident}'
93
+ generated = f'Full video: {url}'
94
+ for platform, pp in post.platforms.items():
95
+ if (pp.source_video_id not in {None, ident} or pp.content_origin not in {None, 'youtube_long'}
96
+ or any(link != ident for field in (pp.body, pp.link, pp.first_comment)
97
+ for link in _video_links(field))):
98
+ raise ValueError(f'{platform.value}: explicit source/link conflicts with source-videos.yml')
99
+ pp.source_video_id, pp.content_origin = ident, 'youtube_long'
100
+ if platform is Platform.TIKTOK:
101
+ if pp.first_comment:
102
+ raise ValueError('TikTok source linking uses the caption; first comments are unsupported')
103
+ if ident not in set(_video_links(pp.body)):
104
+ pp.body = f'{pp.body}\n\n{generated}'
105
+ elif ident not in set(_video_links(pp.first_comment)):
106
+ pp.first_comment = f'{pp.first_comment}\n{generated}' if pp.first_comment else generated
107
+ post.source_mapping_digest = _digest(post.slug, entry)
108
+ return post
109
+
110
+
111
+ def verify_source_binding(brand, slug, mapping_digest):
112
+ if mapping_digest is not None:
113
+ entry = _entry(brand, slug)
114
+ if entry is None or _digest(slug, entry) != mapping_digest:
115
+ raise ValueError('source mapping changed after preview; run --dry-run and approve again')
116
+
117
+
118
+ def verify_source_mapping(brand, post):
119
+ verify_source_binding(brand, post.slug, post.source_mapping_digest)
@@ -0,0 +1 @@
1
+ """Shared SocialCli visual system. Assets are compiled by scripts/build_ui_assets.py."""