@iducky/media-agent 1.1.0
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 +90 -0
- package/SHA256SUMS +94 -0
- package/bin/media-agent.mjs +89 -0
- package/docs/guides/capabilities.md +103 -0
- package/docs/guides/image-text-publishing.md +81 -0
- package/docs/guides/installation.md +103 -0
- package/docs/guides/runtime.md +13 -0
- package/manifest.json +382 -0
- package/package.json +42 -0
- package/pyproject.toml +20 -0
- package/resources/capabilities.json +34 -0
- package/resources/configs/accounts.yaml +21 -0
- package/resources/configs/profile.template.json +20 -0
- package/resources/configs/ranking-profiles.yaml +21 -0
- package/resources/configs/toutiao-profile.template.json +22 -0
- package/resources/configs/xiaohongshu-profile.template.json +27 -0
- package/resources/data/industry-taxonomy.yaml +326 -0
- package/skills/douyin-competitor-collect/SKILL.md +177 -0
- package/skills/douyin-competitor-collect/agents/openai.yaml +4 -0
- package/skills/douyin-competitor-collect/references/output-schema.md +178 -0
- package/skills/douyin-creator-image-text-publish/SKILL.md +44 -0
- package/skills/douyin-creator-image-text-publish/agents/openai.yaml +4 -0
- package/skills/douyin-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
- package/skills/douyin-creator-image-text-publish/references/execution-contract.md +46 -0
- package/skills/douyin-creator-image-text-publish/references/upstream.md +30 -0
- package/skills/douyin-creator-index/SKILL.md +32 -0
- package/skills/douyin-creator-index/agents/openai.yaml +4 -0
- package/skills/douyin-creator-login/SKILL.md +58 -0
- package/skills/douyin-creator-login/agents/openai.yaml +4 -0
- package/skills/douyin-creator-publish/SKILL.md +56 -0
- package/skills/douyin-creator-publish/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-leads/SKILL.md +26 -0
- package/skills/douyin-enterprise-leads/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-leads-login/SKILL.md +36 -0
- package/skills/douyin-enterprise-leads-login/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-short-video-export/SKILL.md +29 -0
- package/skills/douyin-enterprise-short-video-export/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-video-rankings/SKILL.md +51 -0
- package/skills/douyin-enterprise-video-rankings/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-video-rankings/references/industry-taxonomy.md +29 -0
- package/skills/douyin-web-login/SKILL.md +102 -0
- package/skills/douyin-web-login/agents/openai.yaml +4 -0
- package/skills/toutiao-creator-article-draft/SKILL.md +154 -0
- package/skills/toutiao-creator-article-draft/agents/openai.yaml +4 -0
- package/skills/toutiao-web-login/SKILL.md +96 -0
- package/skills/toutiao-web-login/agents/openai.yaml +4 -0
- package/skills/xiaohongshu-creator-image-text-publish/SKILL.md +46 -0
- package/skills/xiaohongshu-creator-image-text-publish/agents/openai.yaml +4 -0
- package/skills/xiaohongshu-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
- package/skills/xiaohongshu-creator-image-text-publish/references/execution-contract.md +46 -0
- package/skills/xiaohongshu-creator-image-text-publish/references/upstream.md +31 -0
- package/skills/xiaohongshu-creator-login/SKILL.md +106 -0
- package/skills/xiaohongshu-creator-login/agents/openai.yaml +4 -0
- package/skills/xiaohongshu-creator-publish/SKILL.md +153 -0
- package/skills/xiaohongshu-creator-publish/agents/openai.yaml +4 -0
- package/src/media_agent/__init__.py +1 -0
- package/src/media_agent/cli.py +28 -0
- package/src/media_agent/commands.sh +436 -0
- package/src/media_agent/platforms/__init__.py +1 -0
- package/src/media_agent/platforms/douyin/__init__.py +1 -0
- package/src/media_agent/platforms/douyin/check_login.py +196 -0
- package/src/media_agent/platforms/douyin/collect_industry_taxonomy.py +184 -0
- package/src/media_agent/platforms/douyin/collect_video_rankings.py +352 -0
- package/src/media_agent/platforms/douyin/douyin_full_login.py +391 -0
- package/src/media_agent/platforms/douyin/douyin_hotspot_v2.py +454 -0
- package/src/media_agent/platforms/douyin/douyin_publish.py +1135 -0
- package/src/media_agent/platforms/douyin/enterprise_login.py +128 -0
- package/src/media_agent/platforms/douyin/export_short_video.py +70 -0
- package/src/media_agent/platforms/douyin/login_controller.py +508 -0
- package/src/media_agent/platforms/douyin/validate_industry_taxonomy.py +152 -0
- package/src/media_agent/platforms/douyin/validate_rankings.py +254 -0
- package/src/media_agent/platforms/toutiao/__init__.py +1 -0
- package/src/media_agent/platforms/toutiao/toutiao_check_login.py +54 -0
- package/src/media_agent/platforms/toutiao/toutiao_login_controller.py +250 -0
- package/src/media_agent/platforms/toutiao/toutiao_login_evidence.py +50 -0
- package/src/media_agent/platforms/toutiao/toutiao_login_ipc.py +70 -0
- package/src/media_agent/platforms/xiaohongshu/__init__.py +1 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_check_login.py +189 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_controller.py +449 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_evidence.py +64 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_ipc.py +120 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_publish.py +925 -0
- package/src/media_agent/runtime/__init__.py +1 -0
- package/src/media_agent/runtime/account_manager.py +672 -0
- package/src/media_agent/runtime/browser.py +5 -0
- package/src/media_agent/runtime/paths.py +7 -0
- package/src/media_agent/script_map.json +23 -0
- package/src/node/config.mjs +48 -0
- package/src/node/integrity.mjs +34 -0
- package/src/node/skills.mjs +85 -0
- package/tools/archive_releases.py +83 -0
- package/tools/artifacts.py +56 -0
- package/tools/build_release.py +82 -0
- package/tools/check_catalog.py +23 -0
- package/tools/install_runtime.py +144 -0
- package/tools/run_tests.py +21 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"account_manager": "media_agent.runtime.account_manager",
|
|
3
|
+
"check_login": "media_agent.platforms.douyin.check_login",
|
|
4
|
+
"collect_industry_taxonomy": "media_agent.platforms.douyin.collect_industry_taxonomy",
|
|
5
|
+
"collect_video_rankings": "media_agent.platforms.douyin.collect_video_rankings",
|
|
6
|
+
"douyin_full_login": "media_agent.platforms.douyin.douyin_full_login",
|
|
7
|
+
"douyin_hotspot_v2": "media_agent.platforms.douyin.douyin_hotspot_v2",
|
|
8
|
+
"douyin_publish": "media_agent.platforms.douyin.douyin_publish",
|
|
9
|
+
"enterprise_login": "media_agent.platforms.douyin.enterprise_login",
|
|
10
|
+
"export_short_video": "media_agent.platforms.douyin.export_short_video",
|
|
11
|
+
"login_controller": "media_agent.platforms.douyin.login_controller",
|
|
12
|
+
"toutiao_check_login": "media_agent.platforms.toutiao.toutiao_check_login",
|
|
13
|
+
"toutiao_login_controller": "media_agent.platforms.toutiao.toutiao_login_controller",
|
|
14
|
+
"toutiao_login_evidence": "media_agent.platforms.toutiao.toutiao_login_evidence",
|
|
15
|
+
"toutiao_login_ipc": "media_agent.platforms.toutiao.toutiao_login_ipc",
|
|
16
|
+
"validate_industry_taxonomy": "media_agent.platforms.douyin.validate_industry_taxonomy",
|
|
17
|
+
"validate_rankings": "media_agent.platforms.douyin.validate_rankings",
|
|
18
|
+
"xiaohongshu_check_login": "media_agent.platforms.xiaohongshu.xiaohongshu_check_login",
|
|
19
|
+
"xiaohongshu_login_controller": "media_agent.platforms.xiaohongshu.xiaohongshu_login_controller",
|
|
20
|
+
"xiaohongshu_login_evidence": "media_agent.platforms.xiaohongshu.xiaohongshu_login_evidence",
|
|
21
|
+
"xiaohongshu_login_ipc": "media_agent.platforms.xiaohongshu.xiaohongshu_login_ipc",
|
|
22
|
+
"xiaohongshu_publish": "media_agent.platforms.xiaohongshu.xiaohongshu_publish"
|
|
23
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import {exists, rejectLinks} from './skills.mjs';
|
|
5
|
+
|
|
6
|
+
// Explicit configuration allowlist; browser sessions never enter the transfer.
|
|
7
|
+
export function transferConfig(home, args) {
|
|
8
|
+
const [operation, directory, ...options] = args;
|
|
9
|
+
if (!['export', 'import'].includes(operation) || !directory || directory.startsWith('--') ||
|
|
10
|
+
options.some(x => x !== '--dry-run') || options.length > 1)
|
|
11
|
+
throw new Error('Usage: media-agent config export|import DIR [--dry-run] [--home DIR]');
|
|
12
|
+
const other = path.resolve(directory.startsWith('~/') ? path.join(os.homedir(), directory.slice(2)) : directory);
|
|
13
|
+
const [source, target] = operation === 'export' ? [home, other] : [other, home];
|
|
14
|
+
rejectLinks(source); rejectLinks(target);
|
|
15
|
+
if (source === target || source.startsWith(target + path.sep) || target.startsWith(source + path.sep))
|
|
16
|
+
throw new Error('Configuration source and destination must be separate');
|
|
17
|
+
if (!fs.statSync(source).isDirectory()) throw new Error('Configuration source must be a directory');
|
|
18
|
+
const names = ['accounts.yaml', 'ranking-profiles.yaml'];
|
|
19
|
+
const profiles = path.join(source, 'profiles');
|
|
20
|
+
rejectLinks(profiles);
|
|
21
|
+
if (exists(profiles)) for (const entry of fs.readdirSync(profiles, {withFileTypes:true})) {
|
|
22
|
+
if (entry.isSymbolicLink()) throw new Error('Linked Profile is not supported');
|
|
23
|
+
if (entry.isDirectory()) names.push(`profiles/${entry.name}/config.json`);
|
|
24
|
+
}
|
|
25
|
+
const files = names.filter(name => exists(path.join(source,name)));
|
|
26
|
+
// Preflight every path before creating any destination.
|
|
27
|
+
for (const name of files) {
|
|
28
|
+
const from = path.join(source,name), to = path.join(target,name);
|
|
29
|
+
rejectLinks(from); rejectLinks(to);
|
|
30
|
+
if (!fs.statSync(from).isFile() || (exists(to) && !fs.statSync(to).isFile()))
|
|
31
|
+
throw new Error(`Expected configuration file: ${name}`);
|
|
32
|
+
for(let p=path.dirname(to); ; p=path.dirname(p)) {
|
|
33
|
+
if(exists(p) && !fs.statSync(p).isDirectory()) throw new Error(`Expected directory: ${p}`);
|
|
34
|
+
if(path.dirname(p)===p) break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!options.includes('--dry-run')) for (const name of files) {
|
|
38
|
+
const to = path.join(target,name);
|
|
39
|
+
fs.mkdirSync(path.dirname(to), {recursive:true});
|
|
40
|
+
const stage = fs.mkdtempSync(path.join(path.dirname(to), '.config-'));
|
|
41
|
+
try {
|
|
42
|
+
fs.copyFileSync(path.join(source,name),path.join(stage,'value'));
|
|
43
|
+
fs.chmodSync(path.join(stage,'value'),0o600);
|
|
44
|
+
fs.renameSync(path.join(stage,'value'),to);
|
|
45
|
+
} finally { fs.rmSync(stage,{recursive:true,force:true}); }
|
|
46
|
+
}
|
|
47
|
+
console.log(`${options.length ? '[DRY-RUN] ' : ''}Configuration ${operation}: ${files.length} files; browser data excluded.`);
|
|
48
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export function verifyIntegrity(root) {
|
|
6
|
+
const marker=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8')).mediaAgentArtifact;
|
|
7
|
+
const manifest=path.join(root,'manifest.json');
|
|
8
|
+
if(!fs.existsSync(manifest) && !marker) return;
|
|
9
|
+
try {
|
|
10
|
+
if(fs.lstatSync(manifest).isSymbolicLink()) throw new Error('linked manifest');
|
|
11
|
+
const files=JSON.parse(fs.readFileSync(manifest,'utf8')).files;
|
|
12
|
+
if(!files || !Object.keys(files).length) throw new Error('empty manifest');
|
|
13
|
+
const actual=[];
|
|
14
|
+
function walk(dir,prefix='') {
|
|
15
|
+
for(const e of fs.readdirSync(dir,{withFileTypes:true})) {
|
|
16
|
+
const name=prefix+e.name, full=path.join(dir,e.name);
|
|
17
|
+
if(e.isSymbolicLink()) throw new Error(`linked file ${name}`);
|
|
18
|
+
if(e.isDirectory()) walk(full,name+'/');
|
|
19
|
+
else if(e.isFile()) actual.push(name);
|
|
20
|
+
else throw new Error(`special file ${name}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
walk(root);
|
|
24
|
+
const expected=[...Object.keys(files),'manifest.json','SHA256SUMS'].sort();
|
|
25
|
+
if(JSON.stringify(actual.sort())!==JSON.stringify(expected)) throw new Error('missing or unexpected files');
|
|
26
|
+
for(const [name,record] of Object.entries(files)) {
|
|
27
|
+
if(name.startsWith('/') || name.includes('\\') || name.split('/').some(p=>p==='..'||p==='.'||!p)) throw new Error('invalid path');
|
|
28
|
+
const buffer=fs.readFileSync(path.join(root,name));
|
|
29
|
+
if(buffer.length!==record.size || crypto.createHash('sha256').update(buffer).digest('hex')!==record.sha256) throw new Error(`mismatch ${name}`);
|
|
30
|
+
}
|
|
31
|
+
const sums=Object.keys(files).sort().map(n=>`${files[n].sha256} ${n}\n`).join('');
|
|
32
|
+
if(fs.readFileSync(path.join(root,'SHA256SUMS'),'utf8')!==sums) throw new Error('invalid SHA256SUMS');
|
|
33
|
+
} catch(error) { throw new Error(`Artifact integrity: ${error.message}`); }
|
|
34
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const exists = p => { try { fs.lstatSync(p); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } };
|
|
6
|
+
export function rejectLinks(p) {
|
|
7
|
+
for (let q = path.resolve(p); ; q = path.dirname(q)) {
|
|
8
|
+
if (exists(q) && fs.lstatSync(q).isSymbolicLink()) {
|
|
9
|
+
if (!(process.platform === 'darwin' && ['/var','/tmp'].includes(q) && fs.realpathSync(q) === '/private'+q))
|
|
10
|
+
throw new Error(`Symlink path is not supported: ${q}`);
|
|
11
|
+
}
|
|
12
|
+
if (path.dirname(q) === q) break;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function inventory(root) {
|
|
16
|
+
const result = {};
|
|
17
|
+
function visit(dir, prefix = '') {
|
|
18
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a,b)=>a.name.localeCompare(b.name))) {
|
|
19
|
+
const relative = prefix + e.name, full = path.join(dir,e.name);
|
|
20
|
+
if (e.isSymbolicLink()) throw new Error(`Symlink in Skill: ${full}`);
|
|
21
|
+
if (e.isDirectory()) visit(full, relative + '/');
|
|
22
|
+
else if (e.isFile()) result[relative] = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex');
|
|
23
|
+
else throw new Error(`Special file in Skill: ${full}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
visit(root); return result;
|
|
27
|
+
}
|
|
28
|
+
function equal(a,b) { return JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort()); }
|
|
29
|
+
function archiveLocation(target, name, prefix) {
|
|
30
|
+
const parent = path.join(path.dirname(target), 'retired-skills');
|
|
31
|
+
if (parent === target) throw new Error('Backup directory must be outside the Skill root');
|
|
32
|
+
rejectLinks(parent);
|
|
33
|
+
fs.mkdirSync(parent, {recursive:true});
|
|
34
|
+
return path.join(fs.mkdtempSync(path.join(parent, prefix)), name);
|
|
35
|
+
}
|
|
36
|
+
export function migrateSkills(catalog, target, dryRun = false) {
|
|
37
|
+
for (const rule of catalog.retired_skills) {
|
|
38
|
+
const old=path.join(target,rule.name);
|
|
39
|
+
if (!exists(old)) continue;
|
|
40
|
+
let hashes;
|
|
41
|
+
try { rejectLinks(old); hashes=inventory(old); }
|
|
42
|
+
catch { console.log(`WARNING: preserved linked or unsupported legacy Skill: ${old}`); continue; }
|
|
43
|
+
if (!equal(hashes,rule.sha256)) { console.log(`WARNING: preserved customized legacy Skill: ${old}`); continue; }
|
|
44
|
+
if (dryRun) { console.log(`[DRY-RUN] retire ${old} after installing ${rule.replacement}`); continue; }
|
|
45
|
+
const replacement=path.join(target,rule.replacement);
|
|
46
|
+
rejectLinks(replacement);
|
|
47
|
+
if (!rule.required_files.every(f=>fs.existsSync(path.join(replacement,f)))) throw new Error(`Replacement Skill is incomplete: ${rule.replacement}`);
|
|
48
|
+
const backup=archiveLocation(target,rule.name,'rankings-merge-');
|
|
49
|
+
fs.renameSync(old,backup); console.log(`RETIRED ${old} -> ${backup}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function installSkills(root, target, selected, dryRun=false) {
|
|
53
|
+
const catalog=JSON.parse(fs.readFileSync(path.join(root,'resources/capabilities.json'),'utf8'));
|
|
54
|
+
const names=selected.length ? [...new Set(selected)] : catalog.skills;
|
|
55
|
+
for (const name of names) if (!catalog.skills.includes(name)) throw new Error(`Unknown Skill: ${name}`);
|
|
56
|
+
rejectLinks(target);
|
|
57
|
+
// Validate every source and destination before changing any Skill.
|
|
58
|
+
const jobs=names.map(name=> {
|
|
59
|
+
const src=path.join(root,'skills',name), dst=path.join(target,name);
|
|
60
|
+
rejectLinks(src); rejectLinks(dst);
|
|
61
|
+
if (!fs.existsSync(path.join(src,'SKILL.md'))) throw new Error(`Incomplete Skill: ${name}`);
|
|
62
|
+
const hashes=inventory(src);
|
|
63
|
+
const prior=exists(dst) ? inventory(dst) : null;
|
|
64
|
+
return {name,src,dst,hashes,prior};
|
|
65
|
+
});
|
|
66
|
+
for (const {name,src,dst,hashes,prior} of jobs) {
|
|
67
|
+
if (dryRun) { console.log(`[DRY-RUN] ${src} -> ${dst}`); continue; }
|
|
68
|
+
if (prior && equal(hashes,prior)) { console.log(`UNCHANGED ${name}`); continue; }
|
|
69
|
+
// Stage outside the discovery root; restore old directory if activation fails.
|
|
70
|
+
fs.mkdirSync(path.dirname(target),{recursive:true});
|
|
71
|
+
const stage=fs.mkdtempSync(path.join(path.dirname(target),'.media-agent-skill-'));
|
|
72
|
+
const staged=path.join(stage,name); let backup;
|
|
73
|
+
try {
|
|
74
|
+
fs.cpSync(src,staged,{recursive:true,errorOnExist:true,force:false});
|
|
75
|
+
if (!equal(inventory(staged),hashes)) throw new Error(`Skill copy verification failed: ${name}`);
|
|
76
|
+
fs.mkdirSync(target,{recursive:true});
|
|
77
|
+
if (prior) { backup=archiveLocation(target,name,'upgrade-'); fs.renameSync(dst,backup); }
|
|
78
|
+
try { fs.renameSync(staged,dst); }
|
|
79
|
+
catch (error) { if (backup) fs.renameSync(backup,dst); throw error; }
|
|
80
|
+
console.log(`INSTALL ${name}${backup ? ` (backup: ${backup})` : ''}`);
|
|
81
|
+
} finally { fs.rmSync(stage,{recursive:true,force:true}); }
|
|
82
|
+
}
|
|
83
|
+
const selectedRules={...catalog,retired_skills:catalog.retired_skills.filter(r=>names.includes(r.replacement))};
|
|
84
|
+
migrateSkills(selectedRules,target,dryRun);
|
|
85
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Move immutable historical archives to GitHub Releases after download verification."""
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
|
|
11
|
+
ROOT=Path(__file__).resolve().parents[1]
|
|
12
|
+
REPO='myducky/MediaAgentCapabilities'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def gh(*args):
|
|
16
|
+
executable=shutil.which('gh')
|
|
17
|
+
if executable is None:
|
|
18
|
+
fallback=Path.home()/'.local/bin/gh'
|
|
19
|
+
if fallback.is_file(): executable=str(fallback)
|
|
20
|
+
if not executable: raise RuntimeError('GitHub CLI unavailable')
|
|
21
|
+
return subprocess.run([executable,*args],capture_output=True,text=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def checked(*args):
|
|
25
|
+
result=gh(*args)
|
|
26
|
+
if result.returncode: raise RuntimeError(result.stderr)
|
|
27
|
+
return result.stdout
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sha(path): return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def migrate(apply=False):
|
|
34
|
+
archives=sorted((ROOT/'releases').glob('*.tar.gz'),key=lambda p:tuple(map(int,p.name.split('-v')[1].split('.tar')[0].split('.'))))
|
|
35
|
+
if not archives:
|
|
36
|
+
print('No local historical archives; index retained.'); return
|
|
37
|
+
records=[]
|
|
38
|
+
for archive in archives:
|
|
39
|
+
sidecar=Path(str(archive)+'.sha256')
|
|
40
|
+
declared=sidecar.read_text().split()
|
|
41
|
+
if len(declared)!=2 or declared[0]!=sha(archive) or Path(declared[1]).name!=archive.name:
|
|
42
|
+
raise ValueError(f'Historical checksum mismatch: {archive.name}')
|
|
43
|
+
# These immutable archives must be byte-for-byte copies of committed assets.
|
|
44
|
+
for p in (archive,sidecar):
|
|
45
|
+
original=subprocess.check_output(['git','show','HEAD:'+p.relative_to(ROOT).as_posix()],cwd=ROOT)
|
|
46
|
+
if original!=p.read_bytes(): raise ValueError(f'Historical asset differs from HEAD: {p}')
|
|
47
|
+
version=archive.name.split('-v')[1].removesuffix('.tar.gz')
|
|
48
|
+
commit=subprocess.check_output(['git','log','-1','--format=%H','--',archive.relative_to(ROOT).as_posix()],cwd=ROOT,text=True).strip()
|
|
49
|
+
assets=[{'name':p.name,'size':p.stat().st_size,'sha256':sha(p),'url':f'https://github.com/{REPO}/releases/download/v{version}/{p.name}'} for p in (archive,sidecar)]
|
|
50
|
+
records.append({'version':version,'tag':'v'+version,'artifact_import_commit':commit,'assets':assets})
|
|
51
|
+
if not apply:
|
|
52
|
+
print(json.dumps(records,indent=2)); return
|
|
53
|
+
for record in records:
|
|
54
|
+
tag=record['tag']; view=gh('release','view',tag,'--repo',REPO,'--json','assets,tagName,isDraft')
|
|
55
|
+
if view.returncode:
|
|
56
|
+
if 'not found' not in view.stderr.lower(): raise RuntimeError(view.stderr)
|
|
57
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
58
|
+
notes=Path(tmp)/'notes.md'
|
|
59
|
+
notes.write_text('历史不可变制品归档。文件与原仓库内的压缩包及 SHA256 边车完全一致。\n\n此标签指向历史制品在本仓库的保存提交;旧版本构建输入以压缩包内的 manifest 为准。未重新构建、未改变旧版本能力或在线验证状态。\n')
|
|
60
|
+
checked('release','create',tag,'--repo',REPO,'--target',record['artifact_import_commit'],'--title',tag+' 历史制品','--notes-file',str(notes),'--latest=false',*[str(ROOT/'releases'/a['name']) for a in record['assets']])
|
|
61
|
+
else:
|
|
62
|
+
info=json.loads(view.stdout)
|
|
63
|
+
if info.get('isDraft'): raise ValueError(f'Existing release is a draft: {tag}')
|
|
64
|
+
present={a['name'] for a in info['assets']}
|
|
65
|
+
missing=[a for a in record['assets'] if a['name'] not in present]
|
|
66
|
+
if missing: checked('release','upload',tag,'--repo',REPO,*[str(ROOT/'releases'/a['name']) for a in missing])
|
|
67
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
68
|
+
for asset in record['assets']:
|
|
69
|
+
checked('release','download',tag,'--repo',REPO,'--pattern',asset['name'],'--dir',tmp)
|
|
70
|
+
downloaded=Path(tmp)/asset['name']
|
|
71
|
+
if downloaded.stat().st_size!=asset['size'] or sha(downloaded)!=asset['sha256']:
|
|
72
|
+
raise ValueError(f'Remote archive mismatch: {tag}/{asset["name"]}')
|
|
73
|
+
print(f'VERIFIED {tag}: {len(record["assets"])} assets',flush=True)
|
|
74
|
+
# No deletion until every version has been downloaded and verified.
|
|
75
|
+
index=ROOT/'docs/releases/index.json'
|
|
76
|
+
index.write_text(json.dumps({'schema':1,'repository':REPO,'releases':records},ensure_ascii=False,indent=2)+'\n')
|
|
77
|
+
for record in records:
|
|
78
|
+
for asset in record['assets']: (ROOT/'releases'/asset['name']).unlink()
|
|
79
|
+
print('Historical archives moved; docs/releases/index.json retained.')
|
|
80
|
+
|
|
81
|
+
if __name__=='__main__':
|
|
82
|
+
parser=argparse.ArgumentParser(description=__doc__); parser.add_argument('--migrate',action='store_true')
|
|
83
|
+
args=parser.parse_args(); migrate(args.migrate)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared release file inventory and installation integrity verification."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
|
|
6
|
+
FILES=('README.md','package.json','pyproject.toml')
|
|
7
|
+
DIRECTORIES={'bin':{'.mjs'},'src':{'.py','.json','.sh','.mjs'},'tools':{'.py'},'resources':{'.yaml','.json'},'docs/guides':{'.md'}}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def source_payload(root):
|
|
12
|
+
files=[]
|
|
13
|
+
for name in FILES:
|
|
14
|
+
p=root/name
|
|
15
|
+
if not p.is_file() or p.is_symlink(): raise ValueError(f'Missing or linked source: {name}')
|
|
16
|
+
files.append(p)
|
|
17
|
+
for folder,suffixes in DIRECTORIES.items():
|
|
18
|
+
for p in (root/folder).rglob('*'):
|
|
19
|
+
if p.is_symlink(): raise ValueError(f'Linked source: {p}')
|
|
20
|
+
if '__pycache__' in p.parts: continue
|
|
21
|
+
if p.is_file() and p.suffix in suffixes: files.append(p)
|
|
22
|
+
catalog=json.loads((root/'resources/capabilities.json').read_text())
|
|
23
|
+
for name in catalog['skills']:
|
|
24
|
+
for p in (root/'skills'/name).rglob('*'):
|
|
25
|
+
if p.is_symlink(): raise ValueError(f'Linked source: {p}')
|
|
26
|
+
if p.is_file():
|
|
27
|
+
if p.suffix not in ('.md','.yaml') and not p.name.startswith('LICENSE.'):
|
|
28
|
+
raise ValueError(f'Unexpected Skill resource: {p}')
|
|
29
|
+
files.append(p)
|
|
30
|
+
return sorted(set(files))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def file_record(p):
|
|
34
|
+
return {'size':p.stat().st_size,'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def verify_integrity(root):
|
|
38
|
+
marker=json.loads((root/'package.json').read_text()).get('mediaAgentArtifact',False)
|
|
39
|
+
manifest=root/'manifest.json'
|
|
40
|
+
if not manifest.exists() and not marker: return # editable source checkout
|
|
41
|
+
if not manifest.is_file() or manifest.is_symlink(): raise ValueError('Artifact integrity: manifest missing or linked')
|
|
42
|
+
data=json.loads(manifest.read_text()); entries=data.get('files',{})
|
|
43
|
+
if not entries: raise ValueError('Artifact integrity: empty manifest')
|
|
44
|
+
for name,expected in entries.items():
|
|
45
|
+
parts=PurePosixPath(name)
|
|
46
|
+
if parts.is_absolute() or '..' in parts.parts or '\\' in name or name!=parts.as_posix():
|
|
47
|
+
raise ValueError('Artifact integrity: invalid path')
|
|
48
|
+
p=root/name
|
|
49
|
+
if not p.is_file() or p.is_symlink() or file_record(p)!=expected: raise ValueError(f'Artifact integrity: mismatch {name}')
|
|
50
|
+
actual=set()
|
|
51
|
+
for p in root.rglob('*'):
|
|
52
|
+
if p.is_symlink(): raise ValueError(f'Artifact integrity: linked file {p}')
|
|
53
|
+
if p.is_file(): actual.add(p.relative_to(root).as_posix())
|
|
54
|
+
if actual != set(entries)|{'manifest.json','SHA256SUMS'}: raise ValueError('Artifact integrity: missing or unexpected files')
|
|
55
|
+
sums=''.join(f"{entries[n]['sha256']} {n}\n" for n in sorted(entries))
|
|
56
|
+
if (root/'SHA256SUMS').read_text()!=sums: raise ValueError('Artifact integrity: invalid SHA256SUMS')
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build npm and optional offline artifacts exclusively from current source."""
|
|
3
|
+
import argparse
|
|
4
|
+
import gzip
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
import tempfile
|
|
15
|
+
sys.dont_write_bytecode=True
|
|
16
|
+
from artifacts import source_payload, file_record, verify_integrity
|
|
17
|
+
from check_catalog import check
|
|
18
|
+
|
|
19
|
+
ROOT=Path(__file__).resolve().parents[1]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build(output, npm=True):
|
|
23
|
+
check(ROOT)
|
|
24
|
+
files=source_payload(ROOT)
|
|
25
|
+
version=json.loads((ROOT/'package.json').read_text())['version']
|
|
26
|
+
name='media-agent-capabilities-v'+version
|
|
27
|
+
output=output.expanduser().resolve()
|
|
28
|
+
if output == ROOT or ROOT.is_relative_to(output): raise ValueError('Output must not contain the source tree')
|
|
29
|
+
output.mkdir(parents=True,exist_ok=True)
|
|
30
|
+
with tempfile.TemporaryDirectory(prefix='.build-',dir=output) as tmp:
|
|
31
|
+
stage=Path(tmp)/name; stage.mkdir()
|
|
32
|
+
for src in files:
|
|
33
|
+
dest=stage/src.relative_to(ROOT); dest.parent.mkdir(parents=True,exist_ok=True); shutil.copy2(src,dest)
|
|
34
|
+
package=json.loads((stage/'package.json').read_text()); package['mediaAgentArtifact']=True
|
|
35
|
+
(stage/'package.json').write_text(json.dumps(package,ensure_ascii=False,indent=2)+'\n')
|
|
36
|
+
entries={p.relative_to(stage).as_posix():file_record(p) for p in sorted(stage.rglob('*')) if p.is_file()}
|
|
37
|
+
(stage/'manifest.json').write_text(json.dumps({'schema':1,'version':version,'files':entries},ensure_ascii=False,indent=2)+'\n')
|
|
38
|
+
(stage/'SHA256SUMS').write_text(''.join(f"{entries[n]['sha256']} {n}\n" for n in sorted(entries)))
|
|
39
|
+
verify_integrity(stage)
|
|
40
|
+
archive=output/(name+'.tar.gz')
|
|
41
|
+
buffer=io.BytesIO()
|
|
42
|
+
with gzip.GzipFile(fileobj=buffer,mode='wb',filename='',mtime=0) as gz:
|
|
43
|
+
with tarfile.open(fileobj=gz,mode='w') as tar:
|
|
44
|
+
for p in sorted(stage.rglob('*')):
|
|
45
|
+
if not p.is_file(): continue
|
|
46
|
+
info=tar.gettarinfo(str(p),arcname=name+'/'+p.relative_to(stage).as_posix())
|
|
47
|
+
info.uid=info.gid=0; info.uname=info.gname=''; info.mtime=0
|
|
48
|
+
info.mode=0o755 if p.suffix in ('.sh','.mjs') else 0o644
|
|
49
|
+
with p.open('rb') as f: tar.addfile(info,f)
|
|
50
|
+
candidates={archive.name:buffer.getvalue()}
|
|
51
|
+
if npm:
|
|
52
|
+
subprocess.run(['npm','pack','--ignore-scripts','--pack-destination',tmp,'--cache',str(Path(tmp)/'npm-cache')],cwd=stage,check=True,stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
|
|
53
|
+
npm_file=next(Path(tmp).glob('*.tgz')); candidates[npm_file.name]=npm_file.read_bytes()
|
|
54
|
+
# Verify what npm actually included, rather than trusting the files field.
|
|
55
|
+
unpack=Path(tmp)/'npm-check'; unpack.mkdir()
|
|
56
|
+
with tarfile.open(npm_file) as tar:
|
|
57
|
+
for member in tar.getmembers():
|
|
58
|
+
parts=Path(member.name).parts
|
|
59
|
+
if member.issym() or member.islnk() or member.name.startswith('/') or '..' in parts: raise ValueError('Unsafe npm archive member')
|
|
60
|
+
tar.extractall(unpack)
|
|
61
|
+
verify_integrity(unpack/'package')
|
|
62
|
+
for filename,content in candidates.items():
|
|
63
|
+
dest=output/filename
|
|
64
|
+
if dest.exists() and dest.read_bytes()!=content: raise ValueError(f'Refusing to overwrite a different artifact: {dest}; use a fresh output directory')
|
|
65
|
+
for filename,content in candidates.items():
|
|
66
|
+
dest=output/filename; dest.write_bytes(content)
|
|
67
|
+
checksum=hashlib.sha256(content).hexdigest()
|
|
68
|
+
Path(str(dest)+'.sha256').write_text(f'{checksum} {filename}\n')
|
|
69
|
+
print(f'{checksum} {dest}')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main():
|
|
73
|
+
parser=argparse.ArgumentParser(description=__doc__)
|
|
74
|
+
parser.add_argument('--output',type=Path,default=ROOT/'dist')
|
|
75
|
+
parser.add_argument('--no-npm',action='store_true')
|
|
76
|
+
args=parser.parse_args()
|
|
77
|
+
try: build(args.output,not args.no_npm)
|
|
78
|
+
except (OSError,ValueError,subprocess.CalledProcessError) as e:
|
|
79
|
+
print(f'ERROR: {e}',file=sys.stderr); return 1
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
if __name__=='__main__': raise SystemExit(main())
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check the one capability catalog and synchronized distribution metadata."""
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
ROOT=Path(__file__).resolve().parents[1]
|
|
8
|
+
|
|
9
|
+
def check(root=ROOT):
|
|
10
|
+
catalog=json.loads((root/'resources/capabilities.json').read_text())
|
|
11
|
+
names=catalog['skills']
|
|
12
|
+
actual=sorted(p.name for p in (root/'skills').iterdir() if (p/'SKILL.md').is_file())
|
|
13
|
+
if sorted(names)!=actual or len(names)!=len(set(names)): raise ValueError('Skill catalog differs from source directories')
|
|
14
|
+
for name in names:
|
|
15
|
+
if not re.fullmatch(r'[a-z0-9-]+',name): raise ValueError('Invalid Skill name')
|
|
16
|
+
text=(root/'skills'/name/'SKILL.md').read_text()
|
|
17
|
+
if not re.search(r'^name:\s*'+re.escape(name)+r'\s*$',text,re.M): raise ValueError(f'Skill metadata mismatch: {name}')
|
|
18
|
+
package=json.loads((root/'package.json').read_text())
|
|
19
|
+
match=re.search(r'^version\s*=\s*"([^"]+)"',(root/'pyproject.toml').read_text(),re.M)
|
|
20
|
+
if not match or match.group(1)!=package['version']: raise ValueError('Python/npm version mismatch')
|
|
21
|
+
print(f"Catalog OK: {len(names)} Skills; version {package['version']}")
|
|
22
|
+
|
|
23
|
+
if __name__=='__main__': check()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install versioned Python code without moving or replacing account profiles."""
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import uuid
|
|
13
|
+
sys.dont_write_bytecode = True
|
|
14
|
+
from artifacts import source_payload, verify_integrity
|
|
15
|
+
|
|
16
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def reject_links(path):
|
|
20
|
+
for part in (path, *path.parents):
|
|
21
|
+
if part.is_symlink():
|
|
22
|
+
# macOS exposes its normal temporary directory through /var -> /private/var.
|
|
23
|
+
if sys.platform == 'darwin' and str(part) in ('/var', '/tmp') and str(part.resolve()) == '/private' + str(part):
|
|
24
|
+
continue
|
|
25
|
+
raise ValueError(f'Symlink installation path is not supported: {part}')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def source_files(root):
|
|
29
|
+
files = source_payload(root)
|
|
30
|
+
for name in ('manifest.json', 'SHA256SUMS'):
|
|
31
|
+
if (root / name).exists():
|
|
32
|
+
files.append(root / name)
|
|
33
|
+
return files
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def digest(files, root):
|
|
37
|
+
h = hashlib.sha256()
|
|
38
|
+
for p in sorted(files):
|
|
39
|
+
h.update(p.relative_to(root).as_posix().encode())
|
|
40
|
+
h.update(b'\0')
|
|
41
|
+
h.update(p.read_bytes())
|
|
42
|
+
return h.hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def install(target, dry_run=False, no_deps=False):
|
|
46
|
+
verify_integrity(ROOT)
|
|
47
|
+
target = Path(os.path.abspath(target.expanduser()))
|
|
48
|
+
reject_links(target)
|
|
49
|
+
real_target = target.resolve()
|
|
50
|
+
if real_target == ROOT or ROOT in real_target.parents or real_target in ROOT.parents:
|
|
51
|
+
raise ValueError('Runtime home must be separate from the source/package directory')
|
|
52
|
+
files = source_files(ROOT)
|
|
53
|
+
version = json.loads((ROOT / 'package.json').read_text())['version']
|
|
54
|
+
code_id = version + '-' + digest(files, ROOT)[:16]
|
|
55
|
+
app = target / '.app'
|
|
56
|
+
releases = app / 'releases'
|
|
57
|
+
destination = releases / code_id
|
|
58
|
+
for p in (app, releases, target / '.venv', target / 'configs'):
|
|
59
|
+
reject_links(p)
|
|
60
|
+
if p.exists() and not p.is_dir():
|
|
61
|
+
raise ValueError(f'Expected directory: {p}')
|
|
62
|
+
current = app / 'current'
|
|
63
|
+
if current.exists() and not current.is_symlink():
|
|
64
|
+
raise ValueError(f'Refusing non-symlink activation path: {current}')
|
|
65
|
+
templates = {
|
|
66
|
+
'resources/configs/accounts.yaml': 'accounts.yaml',
|
|
67
|
+
'resources/configs/ranking-profiles.yaml': 'ranking-profiles.yaml',
|
|
68
|
+
'resources/data/industry-taxonomy.yaml': 'industry-taxonomy.yaml',
|
|
69
|
+
}
|
|
70
|
+
templates.update({p.relative_to(ROOT).as_posix(): 'configs/' + p.name for p in (ROOT / 'resources/configs').glob('*.json')})
|
|
71
|
+
destinations = [target / dst for dst in templates.values()]
|
|
72
|
+
for p in destinations:
|
|
73
|
+
reject_links(p)
|
|
74
|
+
if p.exists() and not p.is_file():
|
|
75
|
+
raise ValueError(f'Expected regular file: {p}')
|
|
76
|
+
reject_links(destination)
|
|
77
|
+
if dry_run:
|
|
78
|
+
print(f'[DRY-RUN] runtime {version} -> {destination}; dependencies={not no_deps}')
|
|
79
|
+
return
|
|
80
|
+
if sys.version_info < (3, 9):
|
|
81
|
+
raise ValueError('Python >=3.9 required')
|
|
82
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
python = target / '.venv/bin/python'
|
|
84
|
+
if not no_deps:
|
|
85
|
+
if not python.is_file():
|
|
86
|
+
if (target / '.venv').exists():
|
|
87
|
+
raise ValueError('Incomplete .venv; repair it or select a new runtime home')
|
|
88
|
+
subprocess.run([sys.executable, '-m', 'venv', str(target / '.venv')], check=True)
|
|
89
|
+
# pip may generate build metadata; build from a disposable copy so the
|
|
90
|
+
# verified npm artifact and deployed code remain immutable.
|
|
91
|
+
with tempfile.TemporaryDirectory(prefix='media-agent-python-') as temp:
|
|
92
|
+
build = Path(temp)
|
|
93
|
+
shutil.copy2(ROOT / 'pyproject.toml', build / 'pyproject.toml')
|
|
94
|
+
shutil.copytree(ROOT / 'src/media_agent', build / 'src/media_agent')
|
|
95
|
+
subprocess.run([str(python), '-m', 'pip', 'install', str(build) + '[browser]'], check=True)
|
|
96
|
+
releases.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
if not destination.exists():
|
|
98
|
+
stage = Path(tempfile.mkdtemp(prefix='.stage-', dir=releases))
|
|
99
|
+
try:
|
|
100
|
+
for source in files:
|
|
101
|
+
out = stage / source.relative_to(ROOT)
|
|
102
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
shutil.copy2(source, out)
|
|
104
|
+
if digest(source_files(stage), stage) != digest(files, ROOT):
|
|
105
|
+
raise ValueError('Runtime copy mismatch')
|
|
106
|
+
stage.rename(destination)
|
|
107
|
+
finally:
|
|
108
|
+
if stage.exists():
|
|
109
|
+
shutil.rmtree(stage)
|
|
110
|
+
elif digest(source_files(destination), destination) != digest(files, ROOT):
|
|
111
|
+
raise ValueError('Existing runtime code was modified; refusing replacement')
|
|
112
|
+
for src, dst in templates.items():
|
|
113
|
+
out = target / dst
|
|
114
|
+
if not out.exists():
|
|
115
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
shutil.copy2(ROOT / src, out)
|
|
117
|
+
link = app / ('.current-' + uuid.uuid4().hex)
|
|
118
|
+
try:
|
|
119
|
+
link.symlink_to(Path('releases') / code_id, target_is_directory=True)
|
|
120
|
+
os.replace(link, current)
|
|
121
|
+
finally:
|
|
122
|
+
if link.is_symlink():
|
|
123
|
+
link.unlink()
|
|
124
|
+
if (target / 'social.sh').exists() or (target / 'scripts').exists():
|
|
125
|
+
print('NOTICE: Old runtime entries were left untouched. Upgrade installed Skills together with the media-agent CLI; old entries are no longer maintained.')
|
|
126
|
+
print(f'Installed runtime {version}: {target}\nCode: {destination}\nProfiles and existing configuration preserved.')
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main():
|
|
130
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
131
|
+
parser.add_argument('--target', type=Path, default=Path(os.environ.get('MEDIA_AGENT_HOME', str(Path.home() / '.media-agent/social-accounts'))))
|
|
132
|
+
parser.add_argument('--dry-run', action='store_true')
|
|
133
|
+
parser.add_argument('--no-deps', action='store_true', help='Copy code only; dependency provisioning is managed separately')
|
|
134
|
+
args = parser.parse_args()
|
|
135
|
+
try:
|
|
136
|
+
install(args.target, args.dry_run, args.no_deps)
|
|
137
|
+
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as exc:
|
|
138
|
+
print(f'ERROR: {exc}', file=sys.stderr)
|
|
139
|
+
return 1
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == '__main__':
|
|
144
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run every isolated offline test file, including the legacy script suites."""
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
ROOT=Path(__file__).resolve().parents[1]
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
failed=[]
|
|
12
|
+
env=dict(os.environ,PYTHONDONTWRITEBYTECODE='1')
|
|
13
|
+
# Tests choose their own temporary account roots; caller state must not leak in.
|
|
14
|
+
env.pop('MEDIA_AGENT_HOME',None)
|
|
15
|
+
for test in sorted((ROOT/'tests').glob('test_*.py')):
|
|
16
|
+
print(f'\n{test.name}',flush=True)
|
|
17
|
+
if subprocess.run([sys.executable,str(test)],cwd=ROOT,env=env).returncode: failed.append(test.name)
|
|
18
|
+
print(f'\nTest files: {len(list((ROOT/"tests").glob("test_*.py")))}; failed: {failed}',flush=True)
|
|
19
|
+
return bool(failed)
|
|
20
|
+
|
|
21
|
+
if __name__=='__main__': raise SystemExit(main())
|