@iducky/media-agent 1.1.0 → 1.1.1
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.
- package/README.md +3 -3
- package/SHA256SUMS +34 -30
- package/bin/media-agent.mjs +19 -7
- package/docs/guides/capabilities.md +6 -2
- package/docs/guides/installation.md +42 -6
- package/manifest.json +74 -58
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/resources/capabilities.json +43 -1
- package/skills/douyin-creator-index/SKILL.md +2 -0
- package/skills/douyin-creator-publish/SKILL.md +3 -1
- package/skills/douyin-enterprise-short-video-export/SKILL.md +2 -0
- package/skills/douyin-enterprise-video-rankings/SKILL.md +2 -0
- package/skills/xiaohongshu-creator-publish/SKILL.md +3 -1
- package/src/media_agent/cli.py +5 -0
- package/src/media_agent/commands.sh +20 -20
- package/src/media_agent/platforms/douyin/check_login.py +8 -11
- package/src/media_agent/platforms/douyin/collect_industry_taxonomy.py +10 -1
- package/src/media_agent/platforms/douyin/collect_video_rankings.py +14 -5
- package/src/media_agent/platforms/douyin/douyin_hotspot_v2.py +12 -3
- package/src/media_agent/platforms/douyin/douyin_publish.py +65 -218
- package/src/media_agent/platforms/douyin/enterprise_login.py +6 -11
- package/src/media_agent/platforms/douyin/export_short_video.py +10 -1
- package/src/media_agent/platforms/douyin/login_controller.py +4 -15
- package/src/media_agent/platforms/toutiao/toutiao_login_controller.py +4 -22
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_check_login.py +8 -11
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_controller.py +5 -17
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_publish.py +63 -182
- package/src/media_agent/runtime/account_manager.py +16 -88
- package/src/media_agent/runtime/arguments.py +69 -0
- package/src/media_agent/runtime/diagnostics.py +186 -0
- package/src/media_agent/runtime/locking.py +74 -0
- package/src/media_agent/runtime/publish_control.py +98 -0
- package/src/node/skills.mjs +2 -1
- package/tools/artifacts.py +1 -0
- package/tools/install_runtime.py +5 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Read-only inventory for task inspection and upgrade planning."""
|
|
2
|
+
import argparse
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from .locking import guard, process_alive
|
|
11
|
+
from .paths import runtime_home
|
|
12
|
+
|
|
13
|
+
UNFINISHED = {'prepared', 'preparing', 'uploading', 'confirming', 'submitting',
|
|
14
|
+
'verifying', 'indeterminate'}
|
|
15
|
+
SETTLED = {'submitted', 'reviewing', 'published', 'scheduled', 'rejected', 'failed', 'aborted'}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def tasks(home):
|
|
19
|
+
latest, errors = {}, []
|
|
20
|
+
for ledger in sorted((Path(home) / 'tasks').glob('**/*ledger*.jsonl')):
|
|
21
|
+
try:
|
|
22
|
+
for number, line in enumerate(ledger.read_text().splitlines(), 1):
|
|
23
|
+
if not line.strip():
|
|
24
|
+
continue
|
|
25
|
+
try:
|
|
26
|
+
entry = json.loads(line)
|
|
27
|
+
if not isinstance(entry, dict) or not entry.get('task_id'):
|
|
28
|
+
raise ValueError('task_id missing')
|
|
29
|
+
platform = ledger.parent.name
|
|
30
|
+
latest[(platform, entry.get('profile_id'), entry['task_id'])] = {
|
|
31
|
+
**entry, 'ledger': str(ledger.relative_to(home)), 'platform': platform}
|
|
32
|
+
except (ValueError, TypeError):
|
|
33
|
+
errors.append(f'{ledger.relative_to(home)}:{number}: invalid task record')
|
|
34
|
+
except OSError as exc:
|
|
35
|
+
errors.append(f'{ledger.relative_to(home)}: {exc.strerror}')
|
|
36
|
+
retired_path = Path(home) / 'tasks/.retired-publish-tasks.json'
|
|
37
|
+
retired = {}
|
|
38
|
+
if retired_path.exists():
|
|
39
|
+
try:
|
|
40
|
+
retired = json.loads(retired_path.read_text())
|
|
41
|
+
if not isinstance(retired, dict):
|
|
42
|
+
raise ValueError('invalid retirement records')
|
|
43
|
+
except (OSError, ValueError):
|
|
44
|
+
errors.append('Invalid retirement records; upgrade blocked')
|
|
45
|
+
retired = {}
|
|
46
|
+
for entry in latest.values():
|
|
47
|
+
fingerprint = hashlib.sha256(json.dumps(entry, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
48
|
+
entry['record_hash'] = fingerprint
|
|
49
|
+
entry['retired'] = fingerprint in retired
|
|
50
|
+
return list(latest.values()), errors
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def upgrade_report(home):
|
|
54
|
+
home = Path(home).expanduser().resolve()
|
|
55
|
+
active, stale, errors = [], [], []
|
|
56
|
+
for p in sorted((home / 'locks').glob('*.lock')):
|
|
57
|
+
try:
|
|
58
|
+
owner = json.loads(p.read_text())
|
|
59
|
+
if not isinstance(owner, dict):
|
|
60
|
+
raise ValueError('invalid owner')
|
|
61
|
+
record = {k: owner.get(k) for k in ('pid', 'task_id', 'phase')}
|
|
62
|
+
record['profile_id'] = p.stem
|
|
63
|
+
(active if process_alive(owner.get('pid')) else stale).append(record)
|
|
64
|
+
except (OSError, ValueError):
|
|
65
|
+
errors.append(f'locks/{p.name}: unreadable owner')
|
|
66
|
+
events, task_errors = tasks(home)
|
|
67
|
+
errors.extend(task_errors)
|
|
68
|
+
unfinished = [e for e in events if not e['retired'] and e.get('state') not in SETTLED]
|
|
69
|
+
processes = []
|
|
70
|
+
try:
|
|
71
|
+
output = subprocess.check_output(['ps', '-axo', 'pid=,args='], text=True)
|
|
72
|
+
for line in output.splitlines():
|
|
73
|
+
bits = line.strip().split(None, 1)
|
|
74
|
+
if len(bits) == 2 and str(home / 'profiles') + '/' in bits[1] and 'browser_data' in bits[1]:
|
|
75
|
+
processes.append({'pid': int(bits[0]), 'kind': 'profile-browser'})
|
|
76
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
77
|
+
errors.append('Cannot inspect active browser processes')
|
|
78
|
+
active_version = None
|
|
79
|
+
current = home / '.app/current'
|
|
80
|
+
if current.exists() or current.is_symlink():
|
|
81
|
+
try:
|
|
82
|
+
active_version = json.loads((current / 'package.json').read_text())['version']
|
|
83
|
+
except (OSError, ValueError, KeyError):
|
|
84
|
+
errors.append('Activated runtime is incomplete')
|
|
85
|
+
return {'runtime_home': str(home), 'active_version': active_version,
|
|
86
|
+
'legacy_entries': [n for n in ('social.sh', 'scripts') if (home / n).exists()],
|
|
87
|
+
'active_locks': active, 'stale_locks': stale, 'active_browsers': processes,
|
|
88
|
+
'unfinished_tasks': unfinished, 'retired_tasks': [e for e in events if e['retired']], 'errors': errors,
|
|
89
|
+
'safe_to_upgrade': not (active or processes or unfinished or errors),
|
|
90
|
+
'note': 'Snapshot only. Drain callers before setup; preserve and resolve unfinished publishing without retrying.'}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def retire_task(home, profile_id, task_id, reason):
|
|
94
|
+
"""Record an explicit decision to stop a historical task; never rewrite its result."""
|
|
95
|
+
report = upgrade_report(home)
|
|
96
|
+
if report['active_locks'] or report['active_browsers'] or report['errors']:
|
|
97
|
+
raise ValueError('Cannot retire tasks while runtime is active or unreadable')
|
|
98
|
+
entries, _ = tasks(home)
|
|
99
|
+
matches = [e for e in entries if e.get('profile_id') == profile_id and e.get('task_id') == task_id]
|
|
100
|
+
if len(matches) != 1:
|
|
101
|
+
raise ValueError('Select exactly one existing task with --profile-id')
|
|
102
|
+
record = matches[0]
|
|
103
|
+
path = Path(home) / 'tasks/.retired-publish-tasks.json'
|
|
104
|
+
with guard(path):
|
|
105
|
+
data = json.loads(path.read_text()) if path.exists() else {}
|
|
106
|
+
data[record['record_hash']] = {'profile_id': profile_id, 'task_id': task_id,
|
|
107
|
+
'state_preserved': record.get('state'), 'reason': reason,
|
|
108
|
+
'retired_at': datetime.now(timezone.utc).isoformat()}
|
|
109
|
+
fd, temporary = tempfile.mkstemp(prefix='.retired-', dir=path.parent)
|
|
110
|
+
try:
|
|
111
|
+
with os.fdopen(fd, 'w') as f:
|
|
112
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
113
|
+
os.replace(temporary, path)
|
|
114
|
+
finally:
|
|
115
|
+
if os.path.exists(temporary):
|
|
116
|
+
os.unlink(temporary)
|
|
117
|
+
return {'task_id': task_id, 'profile_id': profile_id, 'retired': True,
|
|
118
|
+
'state_preserved': record.get('state'), 'reason': reason}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate_config(home):
|
|
122
|
+
errors, profiles, warnings = [], [], []
|
|
123
|
+
for p in sorted((Path(home) / 'profiles').glob('*/config.json')):
|
|
124
|
+
issues = []
|
|
125
|
+
notes = []
|
|
126
|
+
try:
|
|
127
|
+
d = json.loads(p.read_text())
|
|
128
|
+
account = d.get('account', {})
|
|
129
|
+
if account.get('platform') is None:
|
|
130
|
+
notes.append('legacy config has no platform; verify before platform-specific operations')
|
|
131
|
+
elif account.get('platform') not in ('douyin', 'xiaohongshu', 'toutiao'):
|
|
132
|
+
issues.append('unsupported or missing platform')
|
|
133
|
+
if 'capabilities' not in account:
|
|
134
|
+
notes.append('legacy capability fallback; explicit capabilities recommended')
|
|
135
|
+
elif not isinstance(account['capabilities'], list):
|
|
136
|
+
issues.append('capabilities must be a list')
|
|
137
|
+
if not isinstance(d.get('fingerprint'), dict):
|
|
138
|
+
issues.append('fingerprint missing')
|
|
139
|
+
except (OSError, ValueError, AttributeError):
|
|
140
|
+
issues.append('invalid config.json')
|
|
141
|
+
profiles.append({'profile_id': p.parent.name, 'issues': issues, 'warnings': notes})
|
|
142
|
+
errors.extend(f'{p.parent.name}: {issue}' for issue in issues)
|
|
143
|
+
warnings.extend(f'{p.parent.name}: {note}' for note in notes)
|
|
144
|
+
return {'valid': not errors, 'profiles': profiles, 'errors': errors, 'warnings': warnings}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def main(argv):
|
|
148
|
+
parser = argparse.ArgumentParser(prog='media-agent ' + argv[0])
|
|
149
|
+
command = argv[0]
|
|
150
|
+
if command == 'upgrade':
|
|
151
|
+
parser.add_argument('--check', action='store_true', required=True)
|
|
152
|
+
elif command == 'tasks':
|
|
153
|
+
parser.add_argument('operation', choices=['list', 'show', 'retire'])
|
|
154
|
+
parser.add_argument('task_id', nargs='?')
|
|
155
|
+
parser.add_argument('--profile-id')
|
|
156
|
+
parser.add_argument('--reason')
|
|
157
|
+
elif command == 'config':
|
|
158
|
+
parser.add_argument('operation', choices=['validate'])
|
|
159
|
+
parser.add_argument('--json', action='store_true')
|
|
160
|
+
args = parser.parse_args(argv[1:])
|
|
161
|
+
home = runtime_home()
|
|
162
|
+
if command == 'upgrade':
|
|
163
|
+
result = upgrade_report(home)
|
|
164
|
+
code = 0 if result['safe_to_upgrade'] else 6
|
|
165
|
+
elif command == 'config':
|
|
166
|
+
result = validate_config(home)
|
|
167
|
+
code = 0 if result['valid'] else 1
|
|
168
|
+
else:
|
|
169
|
+
if args.operation in ('show', 'retire') and not args.task_id:
|
|
170
|
+
parser.error(args.operation + ' requires task_id')
|
|
171
|
+
if args.operation == 'retire':
|
|
172
|
+
if not args.profile_id or not args.reason or not args.reason.strip():
|
|
173
|
+
parser.error('retire requires --profile-id and --reason; explicit user disposition is required')
|
|
174
|
+
try:
|
|
175
|
+
result = retire_task(home, args.profile_id, args.task_id, args.reason)
|
|
176
|
+
except ValueError as exc:
|
|
177
|
+
parser.error(str(exc))
|
|
178
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
179
|
+
return 0
|
|
180
|
+
events, errors = tasks(home)
|
|
181
|
+
events = [e for e in events if (not args.profile_id or e.get('profile_id') == args.profile_id)
|
|
182
|
+
and (not args.task_id or e.get('task_id') == args.task_id)]
|
|
183
|
+
result = {'tasks': events, 'errors': errors}
|
|
184
|
+
code = 1 if errors else (5 if args.operation == 'show' and not events else 0)
|
|
185
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
186
|
+
return code
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Process-owned profile locks shared by all supported runtime entry points."""
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
import errno
|
|
5
|
+
import fcntl
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def process_alive(pid):
|
|
12
|
+
# Missing/invalid ownership is unknown, never permission to steal a lock.
|
|
13
|
+
if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 1:
|
|
14
|
+
return True
|
|
15
|
+
try:
|
|
16
|
+
os.kill(pid, 0)
|
|
17
|
+
return True
|
|
18
|
+
except OSError as exc:
|
|
19
|
+
return exc.errno != errno.ESRCH
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@contextmanager
|
|
23
|
+
def guard(path):
|
|
24
|
+
path = Path(path)
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
fd = os.open(str(path) + '.guard', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
27
|
+
try:
|
|
28
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
29
|
+
yield
|
|
30
|
+
finally:
|
|
31
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
32
|
+
os.close(fd)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def acquire(path, task_id, initialize=None, **metadata):
|
|
36
|
+
path = Path(path)
|
|
37
|
+
with guard(path):
|
|
38
|
+
if path.is_symlink():
|
|
39
|
+
return False
|
|
40
|
+
if path.exists():
|
|
41
|
+
try:
|
|
42
|
+
old = json.loads(path.read_text())
|
|
43
|
+
if not isinstance(old, dict) or process_alive(old.get('pid')):
|
|
44
|
+
return False
|
|
45
|
+
except (OSError, ValueError):
|
|
46
|
+
return False
|
|
47
|
+
path.unlink()
|
|
48
|
+
if initialize is not None:
|
|
49
|
+
initialize()
|
|
50
|
+
data = {**metadata, 'pid': os.getpid(), 'task_id': task_id,
|
|
51
|
+
'locked_at': datetime.now(timezone.utc).isoformat()}
|
|
52
|
+
try:
|
|
53
|
+
with path.open('x') as f:
|
|
54
|
+
json.dump(data, f)
|
|
55
|
+
return True
|
|
56
|
+
except FileExistsError:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def release(path, task_id):
|
|
61
|
+
path = Path(path)
|
|
62
|
+
with guard(path):
|
|
63
|
+
if path.is_symlink():
|
|
64
|
+
return False
|
|
65
|
+
if not path.exists():
|
|
66
|
+
return True
|
|
67
|
+
try:
|
|
68
|
+
owner = json.loads(path.read_text())
|
|
69
|
+
except (OSError, ValueError):
|
|
70
|
+
return False
|
|
71
|
+
if not isinstance(owner, dict) or owner.get('pid') != os.getpid() or owner.get('task_id') != task_id:
|
|
72
|
+
return False
|
|
73
|
+
path.unlink()
|
|
74
|
+
return True
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Task-bound signals: confirmation never starts a new browser or guesses a task."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tempfile
|
|
6
|
+
from .locking import guard, process_alive
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def click_publish_once(page):
|
|
10
|
+
button = page.get_by_role('button', name='发布', exact=True)
|
|
11
|
+
if button.count() != 1 or not button.is_visible() or not button.is_enabled():
|
|
12
|
+
return False
|
|
13
|
+
# Playwright additionally verifies actionability/occlusion. If this raises,
|
|
14
|
+
# the caller records an indeterminate result and must not retry the click.
|
|
15
|
+
button.click(timeout=5000)
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def send_confirmation(lock_file, signal_file, entries, profile_id, task_id):
|
|
20
|
+
if not task_id:
|
|
21
|
+
raise ValueError('TASK_ID_REQUIRED')
|
|
22
|
+
with guard(lock_file):
|
|
23
|
+
try:
|
|
24
|
+
owner = json.loads(Path(lock_file).read_text())
|
|
25
|
+
except (OSError, ValueError):
|
|
26
|
+
raise ValueError('PREPARED_PROCESS_REQUIRED')
|
|
27
|
+
if not isinstance(owner, dict):
|
|
28
|
+
raise ValueError('PREPARED_PROCESS_REQUIRED')
|
|
29
|
+
if (owner.get('task_id') != task_id or owner.get('phase') != 'publish'
|
|
30
|
+
or not isinstance(owner.get('pid'), int) or owner['pid'] <= 1
|
|
31
|
+
or not process_alive(owner['pid'])):
|
|
32
|
+
raise ValueError('PREPARED_PROCESS_REQUIRED')
|
|
33
|
+
matching = [e for e in entries if e.get('profile_id') == profile_id and e.get('task_id') == task_id]
|
|
34
|
+
if not matching or matching[-1].get('state') != 'prepared' or not matching[-1].get('sha256'):
|
|
35
|
+
raise ValueError('MATCHING_PREPARED_TASK_REQUIRED')
|
|
36
|
+
signal_file = Path(signal_file)
|
|
37
|
+
signal_file.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
if signal_file.exists():
|
|
39
|
+
raise ValueError('CONFIRMATION_ALREADY_PENDING')
|
|
40
|
+
# Link a complete file atomically, without replacing an existing signal.
|
|
41
|
+
fd, temp = tempfile.mkstemp(prefix='.confirm-', dir=signal_file.parent)
|
|
42
|
+
try:
|
|
43
|
+
with os.fdopen(fd, 'w') as f:
|
|
44
|
+
json.dump({'action': 'confirm', 'task_id': task_id,
|
|
45
|
+
'profile_id': profile_id, 'sha256': matching[-1].get('sha256'),
|
|
46
|
+
'pid': owner['pid']}, f)
|
|
47
|
+
try:
|
|
48
|
+
os.link(temp, signal_file)
|
|
49
|
+
except FileExistsError:
|
|
50
|
+
raise ValueError('CONFIRMATION_ALREADY_PENDING')
|
|
51
|
+
finally:
|
|
52
|
+
os.unlink(temp)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def consume_confirmation(signal_file, profile_id, task_id, sha256):
|
|
56
|
+
try:
|
|
57
|
+
p = Path(signal_file)
|
|
58
|
+
data = json.loads(p.read_text())
|
|
59
|
+
p.unlink()
|
|
60
|
+
return (data.get('action') == 'confirm' and data.get('profile_id') == profile_id
|
|
61
|
+
and data.get('task_id') == task_id and data.get('sha256') == sha256
|
|
62
|
+
and data.get('pid') == os.getpid())
|
|
63
|
+
except (OSError, ValueError, AttributeError):
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def consume_close(signal_file, task_id):
|
|
68
|
+
p = Path(signal_file)
|
|
69
|
+
try:
|
|
70
|
+
data = json.loads(p.read_text())
|
|
71
|
+
return (data.get('action') == 'close' and data.get('task_id') == task_id
|
|
72
|
+
and data.get('pid') == os.getpid())
|
|
73
|
+
except (OSError, ValueError, AttributeError):
|
|
74
|
+
return False
|
|
75
|
+
finally:
|
|
76
|
+
p.unlink(missing_ok=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def request_close(lock_file, signal_file):
|
|
80
|
+
with guard(lock_file):
|
|
81
|
+
try:
|
|
82
|
+
owner = json.loads(Path(lock_file).read_text())
|
|
83
|
+
except (OSError, ValueError):
|
|
84
|
+
raise ValueError('PUBLISH_PROCESS_REQUIRED')
|
|
85
|
+
if (not isinstance(owner, dict) or owner.get('phase') != 'publish'
|
|
86
|
+
or not isinstance(owner.get('pid'), int) or owner['pid'] <= 1
|
|
87
|
+
or not owner.get('task_id') or not process_alive(owner['pid'])):
|
|
88
|
+
raise ValueError('PUBLISH_PROCESS_REQUIRED')
|
|
89
|
+
destination = Path(signal_file)
|
|
90
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
fd, temporary = tempfile.mkstemp(prefix='.close-', dir=destination.parent)
|
|
92
|
+
try:
|
|
93
|
+
with os.fdopen(fd, 'w') as f:
|
|
94
|
+
json.dump({'action': 'close', 'task_id': owner['task_id'], 'pid': owner['pid']}, f)
|
|
95
|
+
os.replace(temporary, destination)
|
|
96
|
+
finally:
|
|
97
|
+
if os.path.exists(temporary):
|
|
98
|
+
os.unlink(temporary)
|
package/src/node/skills.mjs
CHANGED
|
@@ -16,6 +16,7 @@ function inventory(root) {
|
|
|
16
16
|
const result = {};
|
|
17
17
|
function visit(dir, prefix = '') {
|
|
18
18
|
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a,b)=>a.name.localeCompare(b.name))) {
|
|
19
|
+
if (e.name === '.DS_Store') continue;
|
|
19
20
|
const relative = prefix + e.name, full = path.join(dir,e.name);
|
|
20
21
|
if (e.isSymbolicLink()) throw new Error(`Symlink in Skill: ${full}`);
|
|
21
22
|
if (e.isDirectory()) visit(full, relative + '/');
|
|
@@ -71,7 +72,7 @@ export function installSkills(root, target, selected, dryRun=false) {
|
|
|
71
72
|
const stage=fs.mkdtempSync(path.join(path.dirname(target),'.media-agent-skill-'));
|
|
72
73
|
const staged=path.join(stage,name); let backup;
|
|
73
74
|
try {
|
|
74
|
-
fs.cpSync(src,staged,{recursive:true,errorOnExist:true,force:false});
|
|
75
|
+
fs.cpSync(src,staged,{recursive:true,errorOnExist:true,force:false,filter:source=>path.basename(source)!=='.DS_Store'});
|
|
75
76
|
if (!equal(inventory(staged),hashes)) throw new Error(`Skill copy verification failed: ${name}`);
|
|
76
77
|
fs.mkdirSync(target,{recursive:true});
|
|
77
78
|
if (prior) { backup=archiveLocation(target,name,'upgrade-'); fs.renameSync(dst,backup); }
|
package/tools/artifacts.py
CHANGED
|
@@ -23,6 +23,7 @@ def source_payload(root):
|
|
|
23
23
|
for name in catalog['skills']:
|
|
24
24
|
for p in (root/'skills'/name).rglob('*'):
|
|
25
25
|
if p.is_symlink(): raise ValueError(f'Linked source: {p}')
|
|
26
|
+
if p.name == '.DS_Store': continue
|
|
26
27
|
if p.is_file():
|
|
27
28
|
if p.suffix not in ('.md','.yaml') and not p.name.startswith('LICENSE.'):
|
|
28
29
|
raise ValueError(f'Unexpected Skill resource: {p}')
|
package/tools/install_runtime.py
CHANGED
|
@@ -74,6 +74,11 @@ def install(target, dry_run=False, no_deps=False):
|
|
|
74
74
|
if p.exists() and not p.is_file():
|
|
75
75
|
raise ValueError(f'Expected regular file: {p}')
|
|
76
76
|
reject_links(destination)
|
|
77
|
+
sys.path.insert(0, str(ROOT / 'src'))
|
|
78
|
+
from media_agent.runtime.diagnostics import upgrade_report
|
|
79
|
+
readiness = upgrade_report(target)
|
|
80
|
+
if not readiness['safe_to_upgrade']:
|
|
81
|
+
raise ValueError('Upgrade blocked: active or unfinished tasks, or unreadable state. Run media-agent upgrade --check --json; no dependencies or runtime code were changed.')
|
|
77
82
|
if dry_run:
|
|
78
83
|
print(f'[DRY-RUN] runtime {version} -> {destination}; dependencies={not no_deps}')
|
|
79
84
|
return
|