windows-software-installer-skill 1.0.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 +59 -0
- package/bin/cli.js +136 -0
- package/package.json +36 -0
- package/skill/SKILL.md +246 -0
- package/skill/evals/evals.json +147 -0
- package/skill/references/installer-patterns.md +56 -0
- package/skill/references/scoop-buckets.md +141 -0
- package/skill/scripts/install_windows_software.py +542 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# windows-software-installer-skill
|
|
2
|
+
|
|
3
|
+
> ๐ก๏ธ ๆดๅฎๅ
จ็ Windows ่ฝฏไปถๅฎ่ฃ
ๅจ โ AI Agent Skill
|
|
4
|
+
|
|
5
|
+
[](https://github.com/BingWuJ/windows-software-installer)
|
|
6
|
+
[](https://github.com/BingWuJ/windows-software-installer)
|
|
7
|
+
[](https://github.com/BingWuJ/windows-software-installer)
|
|
8
|
+
|
|
9
|
+
ไธ่กๅฝไปคๅฎ่ฃ
๏ผไธบ [pi Coding Agent](https://pi.dev) ๅ [Claude Code](https://claude.ai/code) ๆไพๅฎๅ
จ็ Windows ่ฝฏไปถๅฎ่ฃ
่ฝๅใ
|
|
10
|
+
|
|
11
|
+
## ๅฟซ้ๅฎ่ฃ
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx windows-software-installer-skill
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
่ชๅจๅฐ skill ๆไปถๅฎ่ฃ
ๅฐ `~/.agents/skills/` ๅ `~/.claude/skills/`ใ
|
|
18
|
+
|
|
19
|
+
## ไฝฟ็จๆนๅผ
|
|
20
|
+
|
|
21
|
+
ๅฎ่ฃ
ๅฎๆๅ๏ผๅจ pi ๆ Claude Code ไธญ็ดๆฅไฝฟ็จ๏ผ
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
/INSTALL vscode # ้่ฟ Scoop ๅฎ่ฃ
|
|
25
|
+
/INSTALL D:\Downloads\setup.exe # ไปๆฌๅฐๅฎ่ฃ
ๅ
ๅฎ่ฃ
|
|
26
|
+
/INSTALL https://example.com/app.exe # ไธ่ฝฝๅนถ้ช่ฏๅๅฎ่ฃ
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## ไธ็งๅฎ่ฃ
ๆจกๅผ
|
|
30
|
+
|
|
31
|
+
| ๆจกๅผ | ่ฏดๆ | ๅฎๅ
จ็บงๅซ |
|
|
32
|
+
|------|------|----------|
|
|
33
|
+
| **Scoop** | ไป Scoop ไปๅบๅฎ่ฃ
| โ
ๆ้ซ |
|
|
34
|
+
| **Local file** | ไปๆฌๅฐ .exe/.msi ๅฎ่ฃ
| โ
ไฟกไปป |
|
|
35
|
+
| **Verified download** | ไธ่ฝฝๅ SHA256/็ญพๅ้ช่ฏ | โ
ๅทฒ้ช่ฏ |
|
|
36
|
+
|
|
37
|
+
## ๅฎๅ
จๆบๅถ
|
|
38
|
+
|
|
39
|
+
- ๐ **SHA256 ๅๅธๆ ก้ช** โ ้ช่ฏไธ่ฝฝๆไปถๅฎๆดๆง
|
|
40
|
+
- ๐ **Authenticode ็ญพๅ้ช่ฏ** โ ็กฎ่ฎคๅๅธ่
่บซไปฝ
|
|
41
|
+
- ๐ **HTTPS ๅผบๅถ** โ ๆๆไธ่ฝฝๅฟ
้กป่ตฐๅ ๅฏ่ฟๆฅ
|
|
42
|
+
- ๐ **C ็ไฟๆค** โ ้ป่ฎคๅฎ่ฃ
ๅฐ `D:\iStall\` ไฟๆ C ็ๅนฒๅ
|
|
43
|
+
|
|
44
|
+
## ่ฆๆฑ
|
|
45
|
+
|
|
46
|
+
- Windows 10/11
|
|
47
|
+
- Node.js >= 16๏ผ็จไบ npx ๅฎ่ฃ
๏ผ
|
|
48
|
+
- Python 3.8+๏ผๅฎ่ฃ
่ๆฌ่ฟ่กๆ้๏ผ
|
|
49
|
+
|
|
50
|
+
## ๆๅจๅฎ่ฃ
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git clone https://github.com/BingWuJ/windows-software-installer ~/.agents/skills/windows-software-installer
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT ยฉ [BingWuJ](https://github.com/BingWuJ)
|
|
59
|
+
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
|
|
8
|
+
// โโโ ๅธธ้ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
9
|
+
const SKILL_NAME = 'windows-software-installer';
|
|
10
|
+
const GREEN = '\x1b[32m';
|
|
11
|
+
const RED = '\x1b[31m';
|
|
12
|
+
const YELLOW = '\x1b[33m';
|
|
13
|
+
const CYAN = '\x1b[36m';
|
|
14
|
+
const BOLD = '\x1b[1m';
|
|
15
|
+
const RESET = '\x1b[0m';
|
|
16
|
+
|
|
17
|
+
// โโโ ๅทฅๅ
ทๅฝๆฐ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
18
|
+
function log(msg, color = '') {
|
|
19
|
+
console.log(`${color}${msg}${RESET}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fail(msg) {
|
|
23
|
+
log(`\n[ERROR] ${msg}`, RED);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* ้ๅฝๅคๅถ็ฎๅฝ๏ผ่ทณ่ฟ __pycache__ ๅ .pyc ๆไปถ
|
|
29
|
+
*/
|
|
30
|
+
function copyRecursiveSync(src, dest) {
|
|
31
|
+
const stat = fs.statSync(src);
|
|
32
|
+
if (stat.isDirectory()) {
|
|
33
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
34
|
+
for (const entry of fs.readdirSync(src)) {
|
|
35
|
+
if (entry === '__pycache__' || entry.endsWith('.pyc')) continue;
|
|
36
|
+
copyRecursiveSync(path.join(src, entry), path.join(dest, entry));
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
fs.copyFileSync(src, dest);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// โโโ ไธปๆต็จ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
44
|
+
|
|
45
|
+
// 1. ๆฃๆตๆไฝ็ณป็ป
|
|
46
|
+
if (process.platform !== 'win32') {
|
|
47
|
+
fail('ๆญค skill ไป
ๆฏๆ Windows ็ณป็ปใ');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. ็กฎๅฎ่ทฏๅพ
|
|
51
|
+
const pkgRoot = path.resolve(__dirname, '..');
|
|
52
|
+
const skillSrc = path.join(pkgRoot, 'skill');
|
|
53
|
+
const homeDir = os.homedir();
|
|
54
|
+
const agentsDir = path.join(homeDir, '.agents', 'skills', SKILL_NAME);
|
|
55
|
+
const claudeDir = path.join(homeDir, '.claude', 'skills', SKILL_NAME);
|
|
56
|
+
|
|
57
|
+
// 3. ๆฃๆฅๅ
ๅ
skill ๆไปถๆฏๅฆๅญๅจ
|
|
58
|
+
if (!fs.existsSync(path.join(skillSrc, 'SKILL.md'))) {
|
|
59
|
+
fail(
|
|
60
|
+
`ๆชๆพๅฐ skill ๆบๆไปถ: ${skillSrc}\n` +
|
|
61
|
+
`npm ๅ
ๅฏ่ฝๅทฒๆๅ๏ผ่ฏทๅฐ่ฏๆธ
้ค npx ็ผๅญๅ้่ฏใ`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 4. ๅฎ่ฃ
ๅฐ ~/.agents/skills/
|
|
66
|
+
log(`\n${BOLD}ๆญฃๅจๅฎ่ฃ
${SKILL_NAME} skill...${RESET}`, CYAN);
|
|
67
|
+
log(` ็ฎๆ : ${agentsDir}\n`);
|
|
68
|
+
|
|
69
|
+
// ๆธ
็ๅทฒๆๅฎ่ฃ
|
|
70
|
+
if (fs.existsSync(agentsDir)) {
|
|
71
|
+
fs.rmSync(agentsDir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
74
|
+
|
|
75
|
+
// ๅคๅถ SKILL.md
|
|
76
|
+
fs.copyFileSync(
|
|
77
|
+
path.join(skillSrc, 'SKILL.md'),
|
|
78
|
+
path.join(agentsDir, 'SKILL.md')
|
|
79
|
+
);
|
|
80
|
+
log(' [OK] SKILL.md', GREEN);
|
|
81
|
+
|
|
82
|
+
// ๅคๅถ scripts/
|
|
83
|
+
const scriptsSrc = path.join(skillSrc, 'scripts');
|
|
84
|
+
if (fs.existsSync(scriptsSrc)) {
|
|
85
|
+
copyRecursiveSync(scriptsSrc, path.join(agentsDir, 'scripts'));
|
|
86
|
+
log(' [OK] scripts/', GREEN);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ๅคๅถ references/
|
|
90
|
+
const refsSrc = path.join(skillSrc, 'references');
|
|
91
|
+
if (fs.existsSync(refsSrc)) {
|
|
92
|
+
copyRecursiveSync(refsSrc, path.join(agentsDir, 'references'));
|
|
93
|
+
log(' [OK] references/', GREEN);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 5. ๅจ ~/.claude/skills/ ๅๅปบ Junction
|
|
97
|
+
log(`\n${BOLD}่ฎพ็ฝฎ Claude Code ้พๆฅ...${RESET}`, CYAN);
|
|
98
|
+
|
|
99
|
+
const claudeSkillsDir = path.join(homeDir, '.claude', 'skills');
|
|
100
|
+
if (!fs.existsSync(claudeSkillsDir)) {
|
|
101
|
+
fs.mkdirSync(claudeSkillsDir, { recursive: true });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ็งป้คๅทฒๆ็ junction / ็ฎๅฝ
|
|
105
|
+
// ๆณจๆ๏ผๅฟ
้กปๅ
ๅคๆญๆฏๅฆไธบ symlink๏ผjunction๏ผ๏ผ็จ unlink ่้ rmSync
|
|
106
|
+
// rmSync ๅฏน junction ๅฏ่ฝไผ้ๅฝๅ ้ค็ฎๆ ๅ
ๅฎน
|
|
107
|
+
if (fs.existsSync(claudeDir)) {
|
|
108
|
+
const stat = fs.lstatSync(claudeDir);
|
|
109
|
+
if (stat.isSymbolicLink()) {
|
|
110
|
+
fs.unlinkSync(claudeDir);
|
|
111
|
+
} else {
|
|
112
|
+
fs.rmSync(claudeDir, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ๅๅปบ Junction๏ผWindows ็ฎๅฝ่ๆฅ๏ผไธ้่ฆ็ฎก็ๅๆ้๏ผ
|
|
117
|
+
try {
|
|
118
|
+
fs.symlinkSync(agentsDir, claudeDir, 'junction');
|
|
119
|
+
log(` [OK] Junction: ${claudeDir}`, GREEN);
|
|
120
|
+
log(` -> ${agentsDir}`, GREEN);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
log(` [WARN] ๆ ๆณๅๅปบ Junction: ${err.message}`, YELLOW);
|
|
123
|
+
log(` ่ฏทๆๅจๆง่ก:`, YELLOW);
|
|
124
|
+
log(` New-Item -ItemType Junction -Path "${claudeDir}" -Target "${agentsDir}"`, YELLOW);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 6. ๅฎ่ฃ
ๆๅ
|
|
128
|
+
log(`\n${'='.repeat(55)}`, GREEN);
|
|
129
|
+
log(` ${SKILL_NAME} ๅฎ่ฃ
ๆๅ!`, GREEN);
|
|
130
|
+
log(`${'='.repeat(55)}`, GREEN);
|
|
131
|
+
log(`\n${BOLD}ๅจ pi ๆ Claude Code ไธญไฝฟ็จ:${RESET}`);
|
|
132
|
+
log(` /INSTALL vscode`);
|
|
133
|
+
log(` /INSTALL D:\\Downloads\\setup.exe`);
|
|
134
|
+
log(` /INSTALL https://vendor.example/app.exe`);
|
|
135
|
+
log(`\n${BOLD}ๆไปถๅฎ่ฃ
ไฝ็ฝฎ:${RESET}`);
|
|
136
|
+
log(` ${agentsDir}\n`);
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "windows-software-installer-skill",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Safer Windows software installer skill for AI coding agents (pi, Claude Code). Install via Scoop, local installer, or verified download with SHA256/Authenticode checks.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"windows-software-installer-skill": "bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"skill/",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"sync": "node sync-skill.js",
|
|
15
|
+
"prepublishOnly": "node sync-skill.js"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pi",
|
|
19
|
+
"pi-coding-agent",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"ai-agent",
|
|
22
|
+
"skill",
|
|
23
|
+
"windows",
|
|
24
|
+
"installer",
|
|
25
|
+
"scoop",
|
|
26
|
+
"winget"
|
|
27
|
+
],
|
|
28
|
+
"author": "bingwuj",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=16.0.0"
|
|
32
|
+
},
|
|
33
|
+
"os": [
|
|
34
|
+
"win32"
|
|
35
|
+
]
|
|
36
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: windows-software-installer
|
|
3
|
+
description: >
|
|
4
|
+
Safer Windows software installer for explicit Scoop, local installer, or verified download workflows.
|
|
5
|
+
Use when installing Windows software with controlled modes: (1) Scoop packages, (2) trusted local
|
|
6
|
+
.exe/.msi installers, or (3) HTTPS downloads that pass SHA256 or Authenticode signature checks.
|
|
7
|
+
Also trigger on concise requests such as /INSTALL vscode, /INSTALL ๅพฎไฟก, /INSTALL D:\Downloads\setup.msi,
|
|
8
|
+
or /INSTALL https://vendor.example/app.exe. Avoid for generic "install anything" requests or arbitrary
|
|
9
|
+
unverified download-and-run flows. Note: winget is used only for discovery (to find the official download
|
|
10
|
+
URL or confirm a package exists), not for installation โ winget's --location flag is frequently ignored
|
|
11
|
+
by installers, which would defeat the goal of keeping software off the C drive.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Windows Software Installer
|
|
15
|
+
|
|
16
|
+
Install Windows software through explicit modes with safer defaults. This skill keeps the existing default paths to avoid filling the C drive:
|
|
17
|
+
|
|
18
|
+
- Default install directory: `D:\iStall\{software_name}`
|
|
19
|
+
- Default download cache: `D:\WUDownloadCache`
|
|
20
|
+
|
|
21
|
+
## Quick Entry
|
|
22
|
+
|
|
23
|
+
Use `/INSTALL <payload>` as the short entrypoint for this skill. Treat `/INSTALL` and `/install` the same way.
|
|
24
|
+
|
|
25
|
+
Interpret `<payload>` in this order:
|
|
26
|
+
|
|
27
|
+
1. Absolute Windows path: use `local-file`
|
|
28
|
+
2. HTTPS URL: use `download-verified`
|
|
29
|
+
3. Anything else: treat as a software name โ run `winget search <name>` and `scoop search <name>` only for discovery, then choose one explicit mode before installing
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
/INSTALL vscode
|
|
35
|
+
/INSTALL ๅพฎไฟก
|
|
36
|
+
/INSTALL D:\Downloads\Typeless-0.4.6-x64-Setup.exe
|
|
37
|
+
/INSTALL https://vendor.example/app.exe
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Rules:
|
|
41
|
+
|
|
42
|
+
- `/INSTALL` is only a shorter entrypoint. It does not bypass any security checks.
|
|
43
|
+
- Do not guess download links from vague software names.
|
|
44
|
+
- Reject non-HTTPS URLs.
|
|
45
|
+
- Reject local files that are not `.exe` or `.msi`.
|
|
46
|
+
- If the request is more complex than a single payload, fall back to the explicit CLI arguments below.
|
|
47
|
+
- For software-name payloads, discovery may inform the choice, but do not auto-switch modes during installation.
|
|
48
|
+
|
|
49
|
+
## Modes
|
|
50
|
+
|
|
51
|
+
### `scoop`
|
|
52
|
+
Use when the software exists in Scoop and you want the lowest-risk path. Note: `--installer-type` is not needed for scoop mode, Scoop manages the installation internally.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python scripts/install_windows_software.py \
|
|
56
|
+
--mode scoop \
|
|
57
|
+
--software-name vscode
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Optional bucket:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python scripts/install_windows_software.py \
|
|
64
|
+
--mode scoop \
|
|
65
|
+
--software-name neovim-nightly \
|
|
66
|
+
--scoop-bucket nightly
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### `local-file`
|
|
70
|
+
Use when the installer has already been downloaded manually and you trust the file source.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
python scripts/install_windows_software.py \
|
|
74
|
+
--mode local-file \
|
|
75
|
+
--software-name typeless \
|
|
76
|
+
--local-file "D:\Downloads\Typeless-0.4.6-x64-Setup.exe" \
|
|
77
|
+
--installer-type nsis
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### `download-verified`
|
|
81
|
+
Use when you need the script to download the installer itself. Only HTTPS is allowed. The installer must either:
|
|
82
|
+
|
|
83
|
+
- pass `--sha256`, or
|
|
84
|
+
- have a valid Authenticode signature
|
|
85
|
+
|
|
86
|
+
If the signature is valid but the publisher is not in the allowlist, the script asks for confirmation unless `--allow-untrusted-publisher` is provided.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
python scripts/install_windows_software.py \
|
|
90
|
+
--mode download-verified \
|
|
91
|
+
--software-name wechat \
|
|
92
|
+
--download-url "https://example.com/WeChatSetup.exe" \
|
|
93
|
+
--installer-type nsis \
|
|
94
|
+
--publisher Tencent
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
With SHA256:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
python scripts/install_windows_software.py \
|
|
101
|
+
--mode download-verified \
|
|
102
|
+
--software-name sumatrapdf \
|
|
103
|
+
--download-url "https://www.sumatrapdfreader.org/dl/rel/3.5.2/SumatraPDF-3.5.2-64-install.exe" \
|
|
104
|
+
--installer-type nsis \
|
|
105
|
+
--sha256 "<sha256>"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Interactive mode (launches the installer UI with the install directory pre-filled; success here means the installer was launched, and you handle the remaining steps to avoid bundled software):
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python scripts/install_windows_software.py \
|
|
112
|
+
--mode download-verified \
|
|
113
|
+
--software-name wechat \
|
|
114
|
+
--download-url "https://example.com/WeChatSetup.exe" \
|
|
115
|
+
--installer-type nsis \
|
|
116
|
+
--publisher Tencent \
|
|
117
|
+
--interactive
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
With CDN Referer protection (download URL requires a Referer header):
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
python scripts/install_windows_software.py \
|
|
124
|
+
--mode download-verified \
|
|
125
|
+
--software-name shandianshuo \
|
|
126
|
+
--download-url "https://download.shandianshuo.cn/windows/shandianshuo_0.6.7_x64-setup.exe" \
|
|
127
|
+
--installer-type nsis \
|
|
128
|
+
--referer "https://shandianshuo.cn/" \
|
|
129
|
+
--allow-untrusted-publisher
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Security Rules
|
|
133
|
+
|
|
134
|
+
- Use one explicit mode. Discovery is allowed, but installation must use a single chosen mode with no automatic fallback.
|
|
135
|
+
- Only HTTPS download URLs are allowed.
|
|
136
|
+
- Do not pass arbitrary installer argument strings.
|
|
137
|
+
- `download-verified` refuses unsigned installers.
|
|
138
|
+
- Signed installers from unknown publishers require confirmation unless `--allow-untrusted-publisher` is set.
|
|
139
|
+
- `local-file` mode copies the installer into the install directory for execution, but does not delete the original file.
|
|
140
|
+
|
|
141
|
+
## Parameters
|
|
142
|
+
|
|
143
|
+
| Parameter | Required | Modes | Description |
|
|
144
|
+
|---|---|---|---|
|
|
145
|
+
| `--mode` | Yes | all | `scoop`, `local-file`, or `download-verified` |
|
|
146
|
+
| `--software-name` | Yes | all | Safe identifier used for Scoop and default install dir |
|
|
147
|
+
| `--installer-type` | Yes | local-file, download-verified | `nsis`, `inno`, or `msi` |
|
|
148
|
+
| `--scoop-bucket` | No | scoop | Optional Scoop bucket |
|
|
149
|
+
| `--local-file` | Yes | local-file | Absolute path to a trusted `.exe` or `.msi` |
|
|
150
|
+
| `--download-url` | Yes | download-verified | HTTPS URL to the installer |
|
|
151
|
+
| `--sha256` | No | download-verified | Strongest verification when vendor provides a hash |
|
|
152
|
+
| `--publisher` | No | download-verified | Expected publisher hint for signed installers |
|
|
153
|
+
| `--allow-untrusted-publisher` | No | download-verified | Skip the confirmation step for valid but unlisted publishers |
|
|
154
|
+
| `--referer` | No | download-verified | Referer header URL for CDN hotlink protection (e.g. the software's official site) |
|
|
155
|
+
| `--interactive` | No | local-file, download-verified | Launch installer in interactive mode so you can manually uncheck bundled software |
|
|
156
|
+
| `--install-dir` | No | all | Override install destination; default is `D:\iStall\{software_name}` |
|
|
157
|
+
|
|
158
|
+
## Full Auto Workflow (recommended for winget-listed software)
|
|
159
|
+
|
|
160
|
+
When the software has a winget entry with `Installer Type: exe/msi`, use this pipeline to discover the URL automatically and download via Neat Download Manager:
|
|
161
|
+
|
|
162
|
+
```powershell
|
|
163
|
+
# Step 1: find the Package ID
|
|
164
|
+
winget search <name>
|
|
165
|
+
|
|
166
|
+
# Step 2: extract official URL and SHA256
|
|
167
|
+
winget show <PackageId>
|
|
168
|
+
# Look for:
|
|
169
|
+
# Installer Url: https://...
|
|
170
|
+
# Installer SHA256: <hash>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
# Step 3: download-verified with the values from winget show
|
|
175
|
+
python scripts/install_windows_software.py \
|
|
176
|
+
--mode download-verified \
|
|
177
|
+
--software-name <name> \
|
|
178
|
+
--download-url "<Installer Url>" \
|
|
179
|
+
--installer-type <inno|nsis|msi> \
|
|
180
|
+
--sha256 "<Installer SHA256>"
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The script auto-detects Neat Download Manager (via registry) and uses it as the download engine. If NDM is not installed it falls back to aria2c, and if aria2c also fails, it falls back to PowerShell `Invoke-WebRequest` (which supports `--referer` for CDN hotlink protection). After downloading, it verifies the SHA256 and installs to `D:\iStall\<name>`. Use this only after you have explicitly chosen `download-verified` as the install mode.
|
|
184
|
+
|
|
185
|
+
**Note:** `winget show` sometimes lists `Installer Type: msstore` โ those are Microsoft Store apps. They cannot be redirected to D: and this workflow does not apply.
|
|
186
|
+
|
|
187
|
+
## When To Prefer Each Mode
|
|
188
|
+
|
|
189
|
+
- **Discovery first**: Run `winget search <name>` and `scoop search <name>` to find what's available. winget has broader coverage for mainstream consumer software; scoop is better for CLI/dev tools.
|
|
190
|
+
- **Do NOT install via winget** even if it lists the package โ winget's `--location` flag is frequently ignored by the underlying installer, which defeats the goal of keeping software off the C drive.
|
|
191
|
+
- **Use `scoop`** when the package is found in Scoop. Scoop respects the install location because it manages the directory itself.
|
|
192
|
+
- **Use `local-file`** for installers you already downloaded yourself.
|
|
193
|
+
- **Use `download-verified`** when scoop doesn't have it and you need to download from a vendor URL. Always required โ downloading from an "official" URL alone is not sufficient, as CDN compromise or MITM can tamper with the file in transit.
|
|
194
|
+
- **Add `--interactive`** when the installer is known to bundle unwanted software (common with Chinese consumer apps like WeChat, Baidu, 360). The script will pre-fill the install directory and launch the installer UI; you then manually uncheck bundled offers and complete the installation.
|
|
195
|
+
|
|
196
|
+
**Exception**: if the user explicitly accepts C drive installation, winget is fine and simpler.
|
|
197
|
+
|
|
198
|
+
## Finding the Download URL from an Official Website
|
|
199
|
+
|
|
200
|
+
When winget and scoop both come up empty, you need to find the download URL from the vendor's website. The full discovery flow is:
|
|
201
|
+
|
|
202
|
+
**Step 1: Web search to find the official website.**
|
|
203
|
+
|
|
204
|
+
Use a web search tool to look up the software name. The goal is to find the vendor's official site URL (not a third-party download mirror).
|
|
205
|
+
|
|
206
|
+
**Step 2: Fetch the raw HTML and extract download links.**
|
|
207
|
+
|
|
208
|
+
**Do not use webReader / web_fetch_exa / tavily_extract** for this โ these tools render pages into readable markdown and strip HTML tag attributes, so `<a href="https://download.example.com/setup.exe">` download links are lost entirely.
|
|
209
|
+
|
|
210
|
+
Instead, fetch the raw HTML and search for installer URLs:
|
|
211
|
+
|
|
212
|
+
```powershell
|
|
213
|
+
$env:HTTPS_PROXY = 'http://127.0.0.1:10809'
|
|
214
|
+
$env:HTTP_PROXY = 'http://127.0.0.1:10809'
|
|
215
|
+
$env:NODE_USE_ENV_PROXY = '1'
|
|
216
|
+
node -e "fetch('https://official-site.example/').then(r=>r.text()).then(t=>{const matches=t.match(/https?:\/\/[^\s\"'<>]+\.exe/gi); console.log(matches||'no exe links found')}).catch(e=>console.error(e.message))"
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
This returns the direct download URLs embedded in the page. If Node.js is not available, use the PowerShell equivalent:
|
|
220
|
+
|
|
221
|
+
```powershell
|
|
222
|
+
$response = Invoke-WebRequest -Uri 'https://official-site.example/' -UseBasicParsing
|
|
223
|
+
$matches = [regex]::Matches($response.Content, 'https?://[^\s""''<>]+\.exe', 'IgnoreCase')
|
|
224
|
+
if ($matches.Count -gt 0) { $matches.Value } else { Write-Host 'no exe links found' }
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
If the site is a single-page app that loads content via JavaScript, the HTML source may not contain the links โ in that case try the site's GitHub releases page, or search the web for `<software name> download url exe` to find cached or documented download links.
|
|
228
|
+
|
|
229
|
+
## Troubleshooting
|
|
230
|
+
|
|
231
|
+
**Q: The vendor does not publish SHA256.**
|
|
232
|
+
A: Use `download-verified` with a valid digital signature and `--publisher`. If the signer is valid but not allowlisted, review the displayed signer and confirm manually.
|
|
233
|
+
|
|
234
|
+
**Q: I do not want software to install to C:.**
|
|
235
|
+
A: The default install path remains `D:\iStall\{software_name}`. You can also set `--install-dir` explicitly.
|
|
236
|
+
|
|
237
|
+
**Q: Can I still use arbitrary installer switches?**
|
|
238
|
+
A: No. This skill intentionally removes free-form installer arguments to reduce command injection and mismatched installer behavior.
|
|
239
|
+
|
|
240
|
+
**Q: Download fails with 403 Forbidden from CDN.**
|
|
241
|
+
A: Many Chinese software CDNs (e.g. `download.*.cn` subdomains) enforce Referer-based hotlink protection โ they reject downloads that don't carry the official site as the `Referer` header. NDM and aria2c do not send Referer headers, so the script falls back to PowerShell `Invoke-WebRequest`. Use `--referer "https://official-site.example/"` to pass the official site URL. If you see an error mentioning `Referer ACL` or `denied by Referer ACL`, this is the cause.
|
|
242
|
+
|
|
243
|
+
## Reference Materials
|
|
244
|
+
|
|
245
|
+
- See [references/scoop-buckets.md](references/scoop-buckets.md) for bucket guidance.
|
|
246
|
+
- See [references/installer-patterns.md](references/installer-patterns.md) for the installer templates supported by this skill.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_name": "windows-software-installer",
|
|
3
|
+
"evals": [
|
|
4
|
+
{
|
|
5
|
+
"id": 0,
|
|
6
|
+
"prompt": "/INSTALL vscode",
|
|
7
|
+
"expected_output": "่ฏๅซ /INSTALL ไธบ็ฎๆดๅ
ฅๅฃ๏ผๅนถๆ ๅฐๅฐ scoop ๆจกๅผ",
|
|
8
|
+
"assertions": [
|
|
9
|
+
"Recognizes /INSTALL as the skill trigger",
|
|
10
|
+
"Treats vscode as a software name",
|
|
11
|
+
"Uses --mode scoop"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": 1,
|
|
16
|
+
"prompt": "ๅธฎๆ็จ Scoop ๅฎ่ฃ
VS Code",
|
|
17
|
+
"expected_output": "่ฏๅซไธบๆพๅผ scoop ๆจกๅผ๏ผ็ปๅบๆดๅฎๅ
จ็ๆฐๅฝไปคๆ ผๅผ",
|
|
18
|
+
"assertions": [
|
|
19
|
+
"Uses --mode scoop",
|
|
20
|
+
"Uses --software-name vscode"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": 4,
|
|
25
|
+
"prompt": "/INSTALL D:\\Downloads\\app.msi",
|
|
26
|
+
"expected_output": "่ฏๅซ /INSTALL + ็ปๅฏน่ทฏๅพไธบ local-file ๆจกๅผ๏ผๅนถๆจๆญ msi ๅฎ่ฃ
ๅจ็ฑปๅ",
|
|
27
|
+
"assertions": [
|
|
28
|
+
"Recognizes /INSTALL as the skill trigger",
|
|
29
|
+
"Uses --mode local-file",
|
|
30
|
+
"Uses --local-file parameter",
|
|
31
|
+
"Uses --installer-type msi"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": 5,
|
|
36
|
+
"prompt": "/INSTALL https://vendor.example/app.exe",
|
|
37
|
+
"expected_output": "่ฏๅซ /INSTALL + HTTPS URL ไธบ download-verified ๆจกๅผ",
|
|
38
|
+
"assertions": [
|
|
39
|
+
"Recognizes /INSTALL as the skill trigger",
|
|
40
|
+
"Uses --mode download-verified",
|
|
41
|
+
"Uses --download-url",
|
|
42
|
+
"Mentions SHA256 or Authenticode verification"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": 2,
|
|
47
|
+
"prompt": "ๅธฎๆไปๅฎ็ฝไธ่ฝฝๅฎ่ฃ
ๅพฎไฟก๏ผๅฆๆๆฒกๆ SHA256 ๅฐฑ็จ็ญพๅๆ ก้ช",
|
|
48
|
+
"expected_output": "่ฏๅซไธบ download-verified ๆจกๅผ๏ผๅนถ่ฆๆฑ HTTPS ไธ็ญพๅๆๅๅธ้ช่ฏ",
|
|
49
|
+
"assertions": [
|
|
50
|
+
"Uses --mode download-verified",
|
|
51
|
+
"Uses --download-url",
|
|
52
|
+
"Mentions SHA256 or Authenticode verification",
|
|
53
|
+
"Mentions publisher confirmation or allowlist"
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": 3,
|
|
58
|
+
"prompt": "ไปๆฌๅฐๆไปถ D:\\Downloads\\app.msi ๅฎ่ฃ
่ฝฏไปถ",
|
|
59
|
+
"expected_output": "่ฏๅซไธบ local-file ๆจกๅผ๏ผๅนถไฝฟ็จ็ปๅฏน่ทฏๅพๅๅฎ่ฃ
ๅจ็ฑปๅ",
|
|
60
|
+
"assertions": [
|
|
61
|
+
"Uses --mode local-file",
|
|
62
|
+
"Uses --local-file parameter",
|
|
63
|
+
"Uses --installer-type msi"
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": 6,
|
|
68
|
+
"prompt": "/INSTALL sumatrapdf --sha256 abc123",
|
|
69
|
+
"expected_output": "download-verified ๆจกๅผ + SHA256 ๅๅธๆ ก้ช",
|
|
70
|
+
"assertions": [
|
|
71
|
+
"Uses --mode download-verified",
|
|
72
|
+
"Uses --download-url",
|
|
73
|
+
"Uses --sha256",
|
|
74
|
+
"Mentions SHA256 verification"
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"id": 7,
|
|
79
|
+
"prompt": "ไปๅฎ็ฝไธ่ฝฝๅฎ่ฃ
้ช็ต่ฏด๏ผๅฎ็ฝๆฏ shandianshuo.cn๏ผๅฏ่ฝๆ CDN ้ฒ็้พ",
|
|
80
|
+
"expected_output": "download-verified ๆจกๅผ + referer ๅๆฐ",
|
|
81
|
+
"assertions": [
|
|
82
|
+
"Uses --mode download-verified",
|
|
83
|
+
"Uses --referer",
|
|
84
|
+
"Mentions CDN or Referer"
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"id": 8,
|
|
89
|
+
"prompt": "ๅฎ่ฃ
ๅพฎไฟก๏ผ่ฆ็จไบคไบๅผๅฎ่ฃ
๏ผๆๆๅฎๆ็ป่ฝฏไปถ",
|
|
90
|
+
"expected_output": "download-verified ๆจกๅผ + interactive ๅๆฐ",
|
|
91
|
+
"assertions": [
|
|
92
|
+
"Uses --mode download-verified",
|
|
93
|
+
"Uses --interactive",
|
|
94
|
+
"Mentions bundled software or manual uncheck"
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"id": 9,
|
|
99
|
+
"prompt": "/INSTALL D:\\Downloads\\app-setup.exe",
|
|
100
|
+
"expected_output": "local-file ๆจกๅผ + exe (้ msi)",
|
|
101
|
+
"assertions": [
|
|
102
|
+
"Uses --mode local-file",
|
|
103
|
+
"Uses --local-file",
|
|
104
|
+
"Mentions installer type for exe"
|
|
105
|
+
]
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"id": 10,
|
|
109
|
+
"prompt": "/INSTALL http://example.com/app.exe",
|
|
110
|
+
"expected_output": "ๆ็ป้ HTTPS URL",
|
|
111
|
+
"assertions": [
|
|
112
|
+
"Rejects non-HTTPS URL",
|
|
113
|
+
"Requires HTTPS download URL"
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"id": 11,
|
|
118
|
+
"prompt": "/INSTALL D:\\Downloads\\readme.txt",
|
|
119
|
+
"expected_output": "ๆ็ป้ exe/msi ๆไปถ",
|
|
120
|
+
"assertions": [
|
|
121
|
+
"Rejects non-exe/msi file",
|
|
122
|
+
"Mentions only exe or msi allowed"
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"id": 12,
|
|
127
|
+
"prompt": "ๅธฎๆๅจ winget ้ๆ็ดข Firefox๏ผ็ถๅ็จๅฎ็ๅฎๆนไธ่ฝฝ้พๆฅๅฎ่ฃ
",
|
|
128
|
+
"expected_output": "winget discover + download-verified ๆจกๅผ๏ผFull Auto Workflow๏ผ",
|
|
129
|
+
"assertions": [
|
|
130
|
+
"Runs winget search for discovery",
|
|
131
|
+
"Uses winget show to get URL and SHA256",
|
|
132
|
+
"Uses --mode download-verified",
|
|
133
|
+
"Uses --sha256 from winget output"
|
|
134
|
+
]
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"id": 13,
|
|
138
|
+
"prompt": "/INSTALL D:\\Downloads\\MyApp-setup.exe ่ฟไธชๅฎ่ฃ
ๅ
ๆฏ Inno Setup ๆ ผๅผ็",
|
|
139
|
+
"expected_output": "local-file ๆจกๅผ + inno ๅฎ่ฃ
ๅจ็ฑปๅ",
|
|
140
|
+
"assertions": [
|
|
141
|
+
"Uses --mode local-file",
|
|
142
|
+
"Uses --installer-type inno",
|
|
143
|
+
"Mentions Inno Setup"
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Supported Installer Patterns
|
|
2
|
+
|
|
3
|
+
This skill no longer accepts free-form installer arguments. It uses built-in templates for a small set of installer types that can reliably honor the requested install directory.
|
|
4
|
+
|
|
5
|
+
## Supported Types
|
|
6
|
+
|
|
7
|
+
### NSIS
|
|
8
|
+
- Base flags: `/S`
|
|
9
|
+
- Install directory: `/D=<path>` appended by the script
|
|
10
|
+
|
|
11
|
+
### Inno Setup
|
|
12
|
+
- Base flags: `/VERYSILENT /NORESTART`
|
|
13
|
+
- Install directory: `/DIR="<path>"` appended by the script
|
|
14
|
+
|
|
15
|
+
### MSI
|
|
16
|
+
- Executed through `msiexec`
|
|
17
|
+
- Base flags: `/quiet /norestart`
|
|
18
|
+
- Install directory: `TARGETDIR=<path> INSTALLDIR=<path>` (both passed for maximum compatibility)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## Verification Rules
|
|
22
|
+
|
|
23
|
+
### `download-verified`
|
|
24
|
+
A download is allowed to run only if one of these conditions is true:
|
|
25
|
+
|
|
26
|
+
1. The provided `--sha256` matches the downloaded file.
|
|
27
|
+
2. The file has a valid Authenticode signature and either:
|
|
28
|
+
- the signer matches the publisher allowlist, or
|
|
29
|
+
- the user confirms a valid but unlisted publisher.
|
|
30
|
+
|
|
31
|
+
Unsigned downloads are rejected.
|
|
32
|
+
|
|
33
|
+
### `local-file`
|
|
34
|
+
Local installers must be `.exe` or `.msi` files on an absolute path.
|
|
35
|
+
|
|
36
|
+
## Interactive Mode (`--interactive`)
|
|
37
|
+
|
|
38
|
+
When `--interactive` is set, the silent flags are omitted but the install directory is still passed. This launches the installer UI with the directory pre-filled. In interactive mode, success means the installer was launched; the user then manually:
|
|
39
|
+
|
|
40
|
+
- Uncheck bundled software offers (common in Chinese consumer apps)
|
|
41
|
+
- Review license agreements
|
|
42
|
+
- Choose custom components
|
|
43
|
+
|
|
44
|
+
**Flags used in interactive mode:**
|
|
45
|
+
|
|
46
|
+
| Type | Interactive flags |
|
|
47
|
+
|------|-------------------|
|
|
48
|
+
| NSIS | `/D=<path>` (no `/S`) |
|
|
49
|
+
| Inno | `/DIR="<path>"` (no `/VERYSILENT /NORESTART`) |
|
|
50
|
+
| MSI | `TARGETDIR=... INSTALLDIR=...` (no `/quiet /norestart`) |
|
|
51
|
+
|
|
52
|
+
Interactive mode is only available for `local-file` and `download-verified` modes, not `scoop`.
|
|
53
|
+
|
|
54
|
+
## Why this skill removed custom arguments
|
|
55
|
+
|
|
56
|
+
The previous version accepted arbitrary installer switches and executed shell strings directly. That made command injection and unsafe installer execution too easy. The current version keeps behavior predictable and auditable by using fixed templates.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Scoop Buckets Reference
|
|
2
|
+
|
|
3
|
+
Common Scoop buckets and their typical software packages.
|
|
4
|
+
|
|
5
|
+
## Official Buckets
|
|
6
|
+
|
|
7
|
+
### `main` (default)
|
|
8
|
+
The default bucket containing stable, well-maintained software.
|
|
9
|
+
|
|
10
|
+
Common packages:
|
|
11
|
+
- `python` - Python programming language
|
|
12
|
+
- `nodejs` - Node.js JavaScript runtime
|
|
13
|
+
- `git` - Git version control
|
|
14
|
+
- `vim` - Vim text editor
|
|
15
|
+
- `neovim` - Neovim text editor
|
|
16
|
+
- `ffmpeg` - FFmpeg multimedia framework
|
|
17
|
+
- `curl` - curl data transfer tool
|
|
18
|
+
- `wget` - wget download utility
|
|
19
|
+
- `grep` - grep text search
|
|
20
|
+
- `tar` - tar archive utility
|
|
21
|
+
|
|
22
|
+
### `extras`
|
|
23
|
+
Software that doesn't meet the criteria for the main bucket but is still useful.
|
|
24
|
+
|
|
25
|
+
Common packages:
|
|
26
|
+
- `vscode` - Visual Studio Code
|
|
27
|
+
- `chrome` - Google Chrome browser
|
|
28
|
+
- `firefox` - Mozilla Firefox browser
|
|
29
|
+
- `notepadplusplus` - Notepad++ text editor
|
|
30
|
+
- `sublime-text` - Sublime Text editor
|
|
31
|
+
- `obsidian` - Obsidian note-taking app
|
|
32
|
+
- `typora` - Typora markdown editor
|
|
33
|
+
- `telegram` - Telegram messenger
|
|
34
|
+
- `discord` - Discord chat app
|
|
35
|
+
- `spotify` - Spotify music streaming
|
|
36
|
+
- `steam` - Steam gaming platform
|
|
37
|
+
- `vlc` - VLC media player
|
|
38
|
+
|
|
39
|
+
### `versions`
|
|
40
|
+
Multiple versions of software (nightly, beta, portable, etc.)
|
|
41
|
+
|
|
42
|
+
Common packages:
|
|
43
|
+
- `python-nightly`
|
|
44
|
+
- `nodejs-nightly`
|
|
45
|
+
- `rust-nightly`
|
|
46
|
+
- `gcc-nightly`
|
|
47
|
+
|
|
48
|
+
### `nightly`
|
|
49
|
+
Nightly builds of software.
|
|
50
|
+
|
|
51
|
+
Common packages:
|
|
52
|
+
- `neovim-nightly`
|
|
53
|
+
- `ruby-nightly`
|
|
54
|
+
|
|
55
|
+
### `nerd-fonts`
|
|
56
|
+
Nerd Fonts - patched fonts for developers.
|
|
57
|
+
|
|
58
|
+
Common packages:
|
|
59
|
+
- `FiraCode-NF`
|
|
60
|
+
- `JetBrainsMono-NF`
|
|
61
|
+
- `Hack-NF`
|
|
62
|
+
- `SourceCodePro-NF`
|
|
63
|
+
|
|
64
|
+
## Third-Party Buckets
|
|
65
|
+
|
|
66
|
+
### `nonportable`
|
|
67
|
+
Contains non-portable versions of software (requires installation, not portable).
|
|
68
|
+
|
|
69
|
+
Usage:
|
|
70
|
+
```bash
|
|
71
|
+
scoop bucket add nonportable
|
|
72
|
+
scoop install nonportable/<app-name>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### `games`
|
|
76
|
+
Game-related software and tools.
|
|
77
|
+
|
|
78
|
+
### `java`
|
|
79
|
+
Java Development Kits and Runtime Environments.
|
|
80
|
+
|
|
81
|
+
Common packages:
|
|
82
|
+
- `temurin-jdk` - Eclipse Temurin JDK (formerly AdoptOpenJDK)
|
|
83
|
+
- `openjdk` - OpenJDK
|
|
84
|
+
- `oraclejdk` - Oracle JDK
|
|
85
|
+
|
|
86
|
+
## Managing Buckets
|
|
87
|
+
|
|
88
|
+
### Add a bucket:
|
|
89
|
+
```bash
|
|
90
|
+
scoop bucket add <bucket-name>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### List known buckets:
|
|
94
|
+
```bash
|
|
95
|
+
scoop bucket known
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### List installed buckets:
|
|
99
|
+
```bash
|
|
100
|
+
scoop bucket list
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Remove a bucket:
|
|
104
|
+
```bash
|
|
105
|
+
scoop bucket rm <bucket-name>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Searching for Software
|
|
109
|
+
|
|
110
|
+
### Search all buckets:
|
|
111
|
+
```bash
|
|
112
|
+
scoop search <software-name>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Search specific bucket:
|
|
116
|
+
```bash
|
|
117
|
+
scoop search <software-name> # Shows results from all buckets with bucket names
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Bucket Selection Tips
|
|
121
|
+
|
|
122
|
+
1. **Try main bucket first** - Most stable and well-maintained packages
|
|
123
|
+
2. **Use extras for GUI apps** - Popular desktop applications
|
|
124
|
+
3. **Use versions for alternatives** - When you need specific versions or variants
|
|
125
|
+
4. **Use nightly for bleeding edge** - Latest development builds (may be unstable)
|
|
126
|
+
5. **Add third-party buckets as needed** - For specialized software
|
|
127
|
+
|
|
128
|
+
## Example: Finding Software
|
|
129
|
+
|
|
130
|
+
To find where VS Code is located:
|
|
131
|
+
```bash
|
|
132
|
+
scoop search vscode
|
|
133
|
+
# Output shows: 'vscode' bucket 'extras'
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Then install with:
|
|
137
|
+
```bash
|
|
138
|
+
scoop install vscode --bucket extras
|
|
139
|
+
# or simply (scoop finds it automatically):
|
|
140
|
+
scoop install vscode
|
|
141
|
+
```
|
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Windows Software Installer
|
|
4
|
+
|
|
5
|
+
Install software on Windows using one of three explicit modes:
|
|
6
|
+
- scoop
|
|
7
|
+
- local-file
|
|
8
|
+
- download-verified
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DEFAULT_INSTALL_ROOT = Path("D:/iStall")
|
|
22
|
+
DEFAULT_DOWNLOAD_CACHE = Path("D:/WUDownloadCache")
|
|
23
|
+
SAFE_NAME_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._+")
|
|
24
|
+
SAFE_BUCKET_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")
|
|
25
|
+
SAFE_EXTENSIONS = {".exe", ".msi"}
|
|
26
|
+
DEFAULT_PUBLISHERS = {
|
|
27
|
+
"Tencent Technology (Shenzhen) Company Limited",
|
|
28
|
+
"Tencent",
|
|
29
|
+
"Microsoft Corporation",
|
|
30
|
+
"Google LLC",
|
|
31
|
+
"Mozilla Corporation",
|
|
32
|
+
"Notepad++ Team",
|
|
33
|
+
"VideoLAN",
|
|
34
|
+
"GitHub, Inc.",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
INSTALLER_TEMPLATES = {
|
|
38
|
+
"nsis": ["/S"],
|
|
39
|
+
"inno": ["/VERYSILENT", "/NORESTART"],
|
|
40
|
+
"msi": ["/quiet", "/norestart"],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def safe_print_command(cmd):
|
|
45
|
+
print("Running:", " ".join(str(part) for part in cmd))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run_command(cmd, check=True, capture_output=True):
|
|
49
|
+
safe_print_command(cmd)
|
|
50
|
+
return subprocess.run(
|
|
51
|
+
cmd,
|
|
52
|
+
check=check,
|
|
53
|
+
capture_output=capture_output,
|
|
54
|
+
text=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def fail(message):
|
|
59
|
+
print(message, file=sys.stderr)
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ps_single_quote(value):
|
|
64
|
+
return str(value).replace("'", "''")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_safe_token(value, label, allowed_chars):
|
|
68
|
+
if not value or any(char not in allowed_chars for char in value):
|
|
69
|
+
raise ValueError(f"Invalid {label}: {value!r}")
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ensure_windows_absolute_path(path_value, label):
|
|
74
|
+
path = Path(path_value)
|
|
75
|
+
if not path.is_absolute():
|
|
76
|
+
raise ValueError(f"{label} must be an absolute path: {path_value}")
|
|
77
|
+
return path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_default_install_dir(software_name):
|
|
81
|
+
return DEFAULT_INSTALL_ROOT / software_name
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_installer_file(path):
|
|
85
|
+
if not path.exists():
|
|
86
|
+
raise ValueError(f"Installer file not found: {path}")
|
|
87
|
+
if not path.is_file():
|
|
88
|
+
raise ValueError(f"Installer path is not a file: {path}")
|
|
89
|
+
if path.suffix.lower() not in SAFE_EXTENSIONS:
|
|
90
|
+
raise ValueError(f"Unsupported installer type: {path.suffix}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate_download_url(url):
|
|
94
|
+
parsed = urlparse(url)
|
|
95
|
+
if parsed.scheme.lower() != "https":
|
|
96
|
+
raise ValueError("download-url must use HTTPS")
|
|
97
|
+
if not parsed.netloc:
|
|
98
|
+
raise ValueError("download-url must include a hostname")
|
|
99
|
+
return parsed
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def verify_sha256(path, expected_sha256):
|
|
103
|
+
digest = hashlib.sha256()
|
|
104
|
+
with path.open("rb") as handle:
|
|
105
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
106
|
+
digest.update(chunk)
|
|
107
|
+
actual = digest.hexdigest().lower()
|
|
108
|
+
return actual == expected_sha256.lower(), actual
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_authenticode_signature(path):
|
|
112
|
+
escaped = str(path).replace("'", "''")
|
|
113
|
+
command = [
|
|
114
|
+
"pwsh",
|
|
115
|
+
"-NoProfile",
|
|
116
|
+
"-Command",
|
|
117
|
+
(
|
|
118
|
+
f"Get-AuthenticodeSignature -FilePath '{escaped}' | "
|
|
119
|
+
"Select-Object @{Name='Status';Expression={$_.Status.ToString()}},"
|
|
120
|
+
"StatusMessage,"
|
|
121
|
+
"@{Name='Signer';Expression={"
|
|
122
|
+
"if ($_.SignerCertificate) { $_.SignerCertificate.Subject } else { '' }"
|
|
123
|
+
"}} | ConvertTo-Json -Compress"
|
|
124
|
+
),
|
|
125
|
+
]
|
|
126
|
+
result = run_command(command, check=False)
|
|
127
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
import json
|
|
132
|
+
|
|
133
|
+
payload = json.loads(result.stdout)
|
|
134
|
+
except Exception:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"status": payload.get("Status", ""),
|
|
139
|
+
"status_message": payload.get("StatusMessage", ""),
|
|
140
|
+
"signer": payload.get("Signer", ""),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def signature_is_valid(signature):
|
|
145
|
+
return signature and signature.get("status") == "Valid" and signature.get("signer")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def signer_matches_allowlist(signature, publisher, allowlisted_publishers):
|
|
149
|
+
signer = signature.get("signer", "")
|
|
150
|
+
if publisher:
|
|
151
|
+
return publisher.lower() in signer.lower()
|
|
152
|
+
return any(candidate.lower() in signer.lower() for candidate in allowlisted_publishers)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def confirm_untrusted_publisher(signature):
|
|
156
|
+
signer = signature.get("signer", "")
|
|
157
|
+
print("Publisher is not in the allowlist.")
|
|
158
|
+
print(f"Detected publisher: {signer}")
|
|
159
|
+
answer = input("Continue anyway? [y/N]: ").strip().lower()
|
|
160
|
+
return answer in {"y", "yes"}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def check_scoop_installed():
|
|
164
|
+
result = run_command(["scoop", "--version"], check=False)
|
|
165
|
+
return result.returncode == 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def install_with_scoop(software_name, bucket=None):
|
|
169
|
+
if not check_scoop_installed():
|
|
170
|
+
print("Scoop is not installed.")
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
command = ["scoop", "install", software_name]
|
|
174
|
+
if bucket:
|
|
175
|
+
command.extend(["--bucket", bucket])
|
|
176
|
+
|
|
177
|
+
result = run_command(command, check=False)
|
|
178
|
+
if result.returncode != 0:
|
|
179
|
+
print(f"Scoop installation of {software_name} failed")
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
verify_result = run_command(["scoop", "list"], check=False)
|
|
183
|
+
if verify_result.returncode != 0:
|
|
184
|
+
print("Could not verify Scoop installation")
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
installed = any(
|
|
188
|
+
line.split()[0].lower() == software_name.lower()
|
|
189
|
+
for line in verify_result.stdout.splitlines()
|
|
190
|
+
if line.strip() and not line.startswith("Installed apps:")
|
|
191
|
+
)
|
|
192
|
+
if installed:
|
|
193
|
+
print(f"Successfully installed {software_name} via Scoop")
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
print(f"Scoop did not report {software_name} as installed")
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def find_ndm():
|
|
201
|
+
"""Return (ndm_exe, ndm_db) if Neat Download Manager is installed, else (None, None)."""
|
|
202
|
+
try:
|
|
203
|
+
import winreg
|
|
204
|
+
for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):
|
|
205
|
+
for subpath in (
|
|
206
|
+
r"Software\Microsoft\Windows\CurrentVersion\Uninstall",
|
|
207
|
+
r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
|
|
208
|
+
):
|
|
209
|
+
try:
|
|
210
|
+
key = winreg.OpenKey(hive, subpath)
|
|
211
|
+
except OSError:
|
|
212
|
+
continue
|
|
213
|
+
i = 0
|
|
214
|
+
while True:
|
|
215
|
+
try:
|
|
216
|
+
sub = winreg.OpenKey(key, winreg.EnumKey(key, i))
|
|
217
|
+
try:
|
|
218
|
+
name = winreg.QueryValueEx(sub, "DisplayName")[0]
|
|
219
|
+
if "Neat Download Manager" in name:
|
|
220
|
+
loc = winreg.QueryValueEx(sub, "InstallLocation")[0]
|
|
221
|
+
exe = Path(loc) / "NeatDM.exe"
|
|
222
|
+
db = Path(os.environ["APPDATA"]) / "NeatDM" / "NeatDB.db"
|
|
223
|
+
if exe.exists() and db.exists():
|
|
224
|
+
return exe, db
|
|
225
|
+
except OSError:
|
|
226
|
+
pass
|
|
227
|
+
finally:
|
|
228
|
+
sub.Close()
|
|
229
|
+
i += 1
|
|
230
|
+
except OSError:
|
|
231
|
+
break
|
|
232
|
+
key.Close()
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
return None, None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def download_with_ndm(download_url, ndm_exe, ndm_db, timeout=600):
|
|
239
|
+
"""Dispatch a URL to Neat Download Manager and wait for completion.
|
|
240
|
+
Returns the Path of the downloaded file."""
|
|
241
|
+
import sqlite3, time
|
|
242
|
+
|
|
243
|
+
conn = sqlite3.connect(str(ndm_db))
|
|
244
|
+
max_id = conn.execute("SELECT COALESCE(MAX(id), 0) FROM downloads").fetchone()[0]
|
|
245
|
+
conn.close()
|
|
246
|
+
|
|
247
|
+
print(f"Sending to NDM: {download_url}")
|
|
248
|
+
subprocess.Popen([str(ndm_exe), download_url])
|
|
249
|
+
|
|
250
|
+
deadline = time.time() + timeout
|
|
251
|
+
last_status = ""
|
|
252
|
+
while time.time() < deadline:
|
|
253
|
+
time.sleep(2)
|
|
254
|
+
try:
|
|
255
|
+
conn = sqlite3.connect(str(ndm_db))
|
|
256
|
+
row = conn.execute(
|
|
257
|
+
"SELECT filename, status, folderpath FROM downloads "
|
|
258
|
+
"WHERE id > ? AND url = ? ORDER BY id DESC LIMIT 1",
|
|
259
|
+
(max_id, download_url),
|
|
260
|
+
).fetchone()
|
|
261
|
+
conn.close()
|
|
262
|
+
except Exception:
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
if not row:
|
|
266
|
+
continue
|
|
267
|
+
filename, status, folderpath = row
|
|
268
|
+
if status != last_status:
|
|
269
|
+
print(f"NDM: {status}")
|
|
270
|
+
last_status = status
|
|
271
|
+
|
|
272
|
+
if "Complete" in status:
|
|
273
|
+
return Path(folderpath) / filename
|
|
274
|
+
if "Error" in status:
|
|
275
|
+
raise RuntimeError(f"NDM download failed: {status}")
|
|
276
|
+
|
|
277
|
+
raise RuntimeError("NDM download timed out after 10 minutes")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def download_with_powershell(download_url, destination, referer=None):
|
|
281
|
+
"""Fallback download using PowerShell Invoke-WebRequest.
|
|
282
|
+
Supports Referer header for CDN hotlink protection."""
|
|
283
|
+
escaped_url = ps_single_quote(download_url)
|
|
284
|
+
escaped_destination = ps_single_quote(destination)
|
|
285
|
+
headers_clause = ""
|
|
286
|
+
if referer:
|
|
287
|
+
escaped_referer = ps_single_quote(referer)
|
|
288
|
+
headers_clause = f"-Headers @{{Referer='{escaped_referer}'}}"
|
|
289
|
+
|
|
290
|
+
cmd = [
|
|
291
|
+
"pwsh", "-NoProfile", "-Command",
|
|
292
|
+
(
|
|
293
|
+
"$ProgressPreference = 'SilentlyContinue'; "
|
|
294
|
+
f"Invoke-WebRequest -Uri '{escaped_url}' -OutFile '{escaped_destination}' "
|
|
295
|
+
f"-UseBasicParsing {headers_clause}"
|
|
296
|
+
),
|
|
297
|
+
]
|
|
298
|
+
result = run_command(cmd, check=False)
|
|
299
|
+
return result.returncode == 0 and destination.exists()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def download_installer(download_url, destination, referer=None):
|
|
303
|
+
"""Download to destination. Tries NDM โ aria2c โ PowerShell (with Referer support)."""
|
|
304
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
|
|
306
|
+
ndm_exe, ndm_db = find_ndm()
|
|
307
|
+
if ndm_exe:
|
|
308
|
+
try:
|
|
309
|
+
downloaded = download_with_ndm(download_url, ndm_exe, ndm_db)
|
|
310
|
+
shutil.copy2(downloaded, destination)
|
|
311
|
+
return True
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
print(f"NDM failed ({exc}), falling back to aria2c")
|
|
314
|
+
|
|
315
|
+
result = run_command(
|
|
316
|
+
["aria2c", "--allow-overwrite=true", "-d", str(destination.parent), "-o", destination.name, download_url],
|
|
317
|
+
check=False,
|
|
318
|
+
)
|
|
319
|
+
if result.returncode == 0:
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
print("aria2c failed, falling back to PowerShell Invoke-WebRequest")
|
|
323
|
+
return download_with_powershell(download_url, destination, referer)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def build_install_command(installer_path, installer_type, install_dir, interactive=False):
|
|
327
|
+
installer_type = installer_type.lower()
|
|
328
|
+
if installer_type == "installshield":
|
|
329
|
+
raise ValueError("installshield installers are not supported because a generic silent install cannot reliably honor --install-dir")
|
|
330
|
+
if installer_type not in INSTALLER_TEMPLATES:
|
|
331
|
+
raise ValueError(f"Unsupported installer type: {installer_type}")
|
|
332
|
+
|
|
333
|
+
if installer_type == "msi":
|
|
334
|
+
cmd = ["msiexec", "/i", str(installer_path)]
|
|
335
|
+
if not interactive:
|
|
336
|
+
cmd.extend(INSTALLER_TEMPLATES["msi"])
|
|
337
|
+
cmd.append(f"TARGETDIR={install_dir}")
|
|
338
|
+
cmd.append(f"INSTALLDIR={install_dir}")
|
|
339
|
+
return cmd
|
|
340
|
+
|
|
341
|
+
command = [str(installer_path)]
|
|
342
|
+
if not interactive:
|
|
343
|
+
command.extend(INSTALLER_TEMPLATES[installer_type])
|
|
344
|
+
|
|
345
|
+
if installer_type == "nsis":
|
|
346
|
+
command.append(f"/D={install_dir}")
|
|
347
|
+
elif installer_type == "inno":
|
|
348
|
+
command.append(f'/DIR="{install_dir}"')
|
|
349
|
+
return command
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def verify_manual_install(install_dir, ignored_paths=None):
|
|
353
|
+
if not install_dir.exists():
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
ignored = {Path(path).resolve() for path in (ignored_paths or [])}
|
|
357
|
+
for pattern in ("*.exe", "*.lnk", "*.dll"):
|
|
358
|
+
for candidate in install_dir.rglob(pattern):
|
|
359
|
+
if candidate.resolve() not in ignored:
|
|
360
|
+
return True
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def prepare_local_installer(local_file, software_name, install_dir):
|
|
365
|
+
source = ensure_windows_absolute_path(local_file, "local-file")
|
|
366
|
+
validate_installer_file(source)
|
|
367
|
+
install_dir.mkdir(parents=True, exist_ok=True)
|
|
368
|
+
|
|
369
|
+
copied_path = install_dir / f"{software_name}-installer{source.suffix.lower()}"
|
|
370
|
+
shutil.copy2(source, copied_path)
|
|
371
|
+
return copied_path
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def prepare_downloaded_installer(download_url, software_name, install_dir, referer=None):
|
|
375
|
+
parsed = validate_download_url(download_url)
|
|
376
|
+
install_dir.mkdir(parents=True, exist_ok=True)
|
|
377
|
+
DEFAULT_DOWNLOAD_CACHE.mkdir(parents=True, exist_ok=True)
|
|
378
|
+
|
|
379
|
+
extension = Path(parsed.path).suffix.lower()
|
|
380
|
+
if extension not in SAFE_EXTENSIONS:
|
|
381
|
+
raise ValueError("download-url must point to an .exe or .msi installer")
|
|
382
|
+
|
|
383
|
+
cache_path = DEFAULT_DOWNLOAD_CACHE / f"{software_name}-installer{extension}"
|
|
384
|
+
if not download_installer(download_url, cache_path, referer):
|
|
385
|
+
raise RuntimeError(f"Failed to download installer from {download_url}")
|
|
386
|
+
|
|
387
|
+
final_path = install_dir / cache_path.name
|
|
388
|
+
shutil.copy2(cache_path, final_path)
|
|
389
|
+
return cache_path, final_path
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def validate_downloaded_installer(installer_path, sha256_value, publisher, allow_untrusted_publisher):
|
|
393
|
+
if sha256_value:
|
|
394
|
+
is_valid, actual_sha256 = verify_sha256(installer_path, sha256_value)
|
|
395
|
+
if not is_valid:
|
|
396
|
+
raise RuntimeError(
|
|
397
|
+
"SHA256 mismatch for downloaded installer. "
|
|
398
|
+
f"Expected {sha256_value.lower()}, got {actual_sha256}"
|
|
399
|
+
)
|
|
400
|
+
print("SHA256 verification passed.")
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
signature = get_authenticode_signature(installer_path)
|
|
404
|
+
if not signature_is_valid(signature):
|
|
405
|
+
raise RuntimeError("Downloaded installer does not have a valid Authenticode signature")
|
|
406
|
+
|
|
407
|
+
if signer_matches_allowlist(signature, publisher, DEFAULT_PUBLISHERS):
|
|
408
|
+
print(f"Trusted signed publisher detected: {signature['signer']}")
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
if allow_untrusted_publisher or confirm_untrusted_publisher(signature):
|
|
412
|
+
print(f"Proceeding with signed but untrusted publisher: {signature['signer']}")
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
raise RuntimeError("Publisher is signed but not allowlisted")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def install_manually(installer_path, installer_type, install_dir):
|
|
419
|
+
command = build_install_command(installer_path, installer_type, install_dir)
|
|
420
|
+
result = run_command(command, check=False)
|
|
421
|
+
if result.returncode != 0:
|
|
422
|
+
print("Manual installation failed")
|
|
423
|
+
return False
|
|
424
|
+
|
|
425
|
+
if verify_manual_install(install_dir, ignored_paths=[installer_path]):
|
|
426
|
+
print(f"Successfully installed software to {install_dir}")
|
|
427
|
+
return True
|
|
428
|
+
|
|
429
|
+
print("Installer exited successfully but installation could not be verified")
|
|
430
|
+
return False
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def install_interactively(installer_path, installer_type, install_dir):
|
|
434
|
+
command = build_install_command(installer_path, installer_type, install_dir, interactive=True)
|
|
435
|
+
safe_print_command(command)
|
|
436
|
+
subprocess.Popen(command)
|
|
437
|
+
print()
|
|
438
|
+
print(f"Installer launched successfully. Install directory pre-set to: {install_dir}")
|
|
439
|
+
print("Installation is not complete yet. Finish the remaining steps manually and uncheck any bundled software.")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def parse_args():
|
|
443
|
+
parser = argparse.ArgumentParser(
|
|
444
|
+
description="Install software on Windows using explicit and safer installation modes"
|
|
445
|
+
)
|
|
446
|
+
parser.add_argument("--mode", required=True, choices=["scoop", "local-file", "download-verified"])
|
|
447
|
+
parser.add_argument("--software-name", "--software_name", required=True, dest="software_name")
|
|
448
|
+
parser.add_argument("--scoop-bucket", "--scoop_bucket", dest="scoop_bucket")
|
|
449
|
+
parser.add_argument("--local-file", "--local_file", dest="local_file")
|
|
450
|
+
parser.add_argument("--download-url", "--download_url", dest="download_url")
|
|
451
|
+
parser.add_argument("--sha256")
|
|
452
|
+
parser.add_argument(
|
|
453
|
+
"--installer-type",
|
|
454
|
+
required=False,
|
|
455
|
+
default=None,
|
|
456
|
+
choices=["nsis", "inno", "msi"],
|
|
457
|
+
)
|
|
458
|
+
parser.add_argument("--install-dir", dest="install_dir")
|
|
459
|
+
parser.add_argument("--publisher")
|
|
460
|
+
parser.add_argument("--referer", dest="referer",
|
|
461
|
+
help="Referer header for CDN hotlink protection (e.g. the official site URL)")
|
|
462
|
+
parser.add_argument("--allow-untrusted-publisher", action="store_true")
|
|
463
|
+
parser.add_argument("--interactive", action="store_true",
|
|
464
|
+
help="Launch installer interactively instead of silent, so you can manually uncheck bundled software")
|
|
465
|
+
return parser.parse_args()
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def main():
|
|
469
|
+
args = parse_args()
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
software_name = validate_safe_token(args.software_name, "software-name", SAFE_NAME_CHARS)
|
|
473
|
+
if args.scoop_bucket:
|
|
474
|
+
validate_safe_token(args.scoop_bucket, "scoop-bucket", SAFE_BUCKET_CHARS)
|
|
475
|
+
|
|
476
|
+
install_dir = (
|
|
477
|
+
ensure_windows_absolute_path(args.install_dir, "install-dir")
|
|
478
|
+
if args.install_dir
|
|
479
|
+
else get_default_install_dir(software_name)
|
|
480
|
+
)
|
|
481
|
+
install_dir.mkdir(parents=True, exist_ok=True)
|
|
482
|
+
except ValueError as error:
|
|
483
|
+
return fail(str(error))
|
|
484
|
+
|
|
485
|
+
if args.mode == "scoop":
|
|
486
|
+
if args.local_file or args.download_url or args.sha256 or args.publisher or args.allow_untrusted_publisher or args.interactive or args.installer_type:
|
|
487
|
+
return fail("scoop mode does not accept local-file, download-url, sha256, publisher, allow-untrusted-publisher, interactive, or installer-type")
|
|
488
|
+
return 0 if install_with_scoop(software_name, args.scoop_bucket) else 1
|
|
489
|
+
|
|
490
|
+
temp_paths = []
|
|
491
|
+
try:
|
|
492
|
+
if args.mode == "local-file":
|
|
493
|
+
if args.download_url or args.sha256 or args.publisher or args.allow_untrusted_publisher or args.referer:
|
|
494
|
+
return fail("local-file mode does not accept download-url, sha256, publisher, allow-untrusted-publisher, or referer")
|
|
495
|
+
if not args.local_file:
|
|
496
|
+
return fail("local-file mode requires --local-file")
|
|
497
|
+
|
|
498
|
+
if not args.installer_type:
|
|
499
|
+
return fail("--installer-type is required for local-file mode")
|
|
500
|
+
installer_path = prepare_local_installer(args.local_file, software_name, install_dir)
|
|
501
|
+
temp_paths.append(installer_path)
|
|
502
|
+
if args.interactive:
|
|
503
|
+
install_interactively(installer_path, args.installer_type, install_dir)
|
|
504
|
+
return 0
|
|
505
|
+
|
|
506
|
+
success = install_manually(installer_path, args.installer_type, install_dir)
|
|
507
|
+
return 0 if success else 1
|
|
508
|
+
|
|
509
|
+
if args.local_file:
|
|
510
|
+
return fail("download-verified mode does not accept --local-file")
|
|
511
|
+
if not args.download_url:
|
|
512
|
+
return fail("download-verified mode requires --download-url")
|
|
513
|
+
|
|
514
|
+
if not args.installer_type:
|
|
515
|
+
return fail("--installer-type is required for download-verified mode")
|
|
516
|
+
cache_path, installer_path = prepare_downloaded_installer(args.download_url, software_name, install_dir, referer=args.referer)
|
|
517
|
+
temp_paths.append(installer_path)
|
|
518
|
+
validate_downloaded_installer(
|
|
519
|
+
installer_path,
|
|
520
|
+
args.sha256,
|
|
521
|
+
args.publisher,
|
|
522
|
+
args.allow_untrusted_publisher,
|
|
523
|
+
)
|
|
524
|
+
if args.interactive:
|
|
525
|
+
install_interactively(installer_path, args.installer_type, install_dir)
|
|
526
|
+
return 0
|
|
527
|
+
|
|
528
|
+
success = install_manually(installer_path, args.installer_type, install_dir)
|
|
529
|
+
return 0 if success else 1
|
|
530
|
+
except (RuntimeError, ValueError) as error:
|
|
531
|
+
return fail(str(error))
|
|
532
|
+
finally:
|
|
533
|
+
for path in temp_paths:
|
|
534
|
+
try:
|
|
535
|
+
if path.exists():
|
|
536
|
+
path.unlink()
|
|
537
|
+
except OSError:
|
|
538
|
+
pass
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
if __name__ == "__main__":
|
|
542
|
+
sys.exit(main())
|