psadt-deploy-skill 0.23.1 → 0.25.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/LICENSE +21 -21
- package/README.md +39 -0
- package/bin/install.mjs +214 -214
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Patrick Taubert, PHAT Consulting GmbH
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Taubert, PHAT Consulting GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -369,6 +369,45 @@ pre-flight fails on non-ASCII without a BOM), and anything that lands in a packa
|
|
|
369
369
|
Recent releases below; the complete history is in **[CHANGELOG.md](CHANGELOG.md)** (nothing is ever removed
|
|
370
370
|
from either).
|
|
371
371
|
|
|
372
|
+
### 0.25.0 - 05.09.2026
|
|
373
|
+
- **New: `Get-PsadtMsiFacts.ps1` reads an MSI in one pass** — identity, signature, SHA256, features with
|
|
374
|
+
component counts, *decoded* upgrade flags, shortcuts, directories, file versions, registry rows and the
|
|
375
|
+
Icon table. For an MSI this probe **is** the research: it is what reveals an auto-updater sitting in its
|
|
376
|
+
own feature (so `ADDLOCAL` replaces post-install cleanup), a `DesktopFeature` you must not install, and
|
|
377
|
+
`MigrateFeatures` on the upgrade row — the flag that means `ADDLOCAL` alone is not enough. Five
|
|
378
|
+
regression guards, each verified to fail on its reintroduced bug.
|
|
379
|
+
- **Fixed: the sandbox test left litter and put the evidence in the wrong place.** It kept one work folder
|
|
380
|
+
per app under the config home forever, and `result.json` plus the PSADT logs lived there with the
|
|
381
|
+
manifest pointing into a profile directory. Evidence now lands in
|
|
382
|
+
`<outputRoot>\<Stem>\SandboxTest\` beside the dossier — not in the package folder, which is what
|
|
383
|
+
IntuneWinAppUtil packs — the logs are recorded in `artifacts.logs[]`, and the scratch is removed.
|
|
384
|
+
`-KeepWorkFolder` keeps it, and a failed run keeps it automatically.
|
|
385
|
+
- **Fixed: the test suite wrote into the real config home.** It now redirects `PSADT_DEPLOY_HOME` to a temp
|
|
386
|
+
directory, with a test that asserts it.
|
|
387
|
+
- **Measured:** PuTTY 0.85 packaged with these lessons applied took **13 min 45 s end to end** — versus 75
|
|
388
|
+
minutes for the Notepad++ run that produced them. The largest single factor: the sandbox test costs zero
|
|
389
|
+
wall-clock time when started as soon as pre-flight goes GREEN, because packaging and the dossier do not
|
|
390
|
+
depend on its verdict.
|
|
391
|
+
|
|
392
|
+
### 0.24.0 - 05.09.2026
|
|
393
|
+
- **New: `Invoke-PsadtSandboxTest.ps1` runs the whole Phase 6 loop in a throwaway Windows Sandbox** —
|
|
394
|
+
Install, detection, Uninstall, detection, Reinstall, Repair, final Uninstall, every action as
|
|
395
|
+
`NT AUTHORITY\SYSTEM` via a scheduled task. **No elevation on the host, and the host is never touched**,
|
|
396
|
+
so the SYSTEM test stops being the step that gets deferred to "a DEV VM later". Every action starts from
|
|
397
|
+
a machine that has never seen the app, so a pass cannot be an artefact of the previous run. The verdict
|
|
398
|
+
is keyed on the detection script; package-specific facts go in via `-PathsPresentAfterInstall` /
|
|
399
|
+
`-PathsAbsentAfterInstall` / `-PathsAbsentAfterUninstall`. Verified end to end on Notepad++ 8.9.8 x64:
|
|
400
|
+
GREEN, all seven steps, 5 min 58 s.
|
|
401
|
+
- **Why it is a script and not a snippet:** a hand-rolled version hit three bugs that each destroyed a full
|
|
402
|
+
VM run and each present as a timeout or a null-reference minutes after launch — `echo %ERRORLEVEL%>file`
|
|
403
|
+
silently becoming the `0>` stdin redirection (empty file, never a number), file existence read as
|
|
404
|
+
completion, and `[string]$null` still being `$null` in Windows PowerShell 5.1 (an empty detection output
|
|
405
|
+
is the *normal* result, so the harness crashed because the package was clean). All three now have
|
|
406
|
+
regression guards that were each verified to fail on the reintroduced bug.
|
|
407
|
+
- Phase 6 in SKILL.md and the guide now lead with the sandbox route; the per-action route stays for apps a
|
|
408
|
+
VM cannot host. New anti-patterns cover hand-rolled harnesses, debugging through a long job instead of a
|
|
409
|
+
two-second local check, and "speeding up" the test by disabling Defender or dropping Uninstall/Repair.
|
|
410
|
+
|
|
372
411
|
### 0.23.1 - 04.09.2026
|
|
373
412
|
- **Fixed: two generators were broken since 0.21.0.** `New-BrowserExtensionPackage.ps1` and
|
|
374
413
|
`New-WindowsFeaturePackage.ps1` died at run time — the per-run `LogName` change had wedged a statement
|
package/bin/install.mjs
CHANGED
|
@@ -1,214 +1,214 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* psadt-deploy-skill - installer for the psadt-deploy Claude Code skill.
|
|
4
|
-
*
|
|
5
|
-
* npx psadt-deploy-skill install/update in ~/.claude/skills/psadt-deploy
|
|
6
|
-
* npx psadt-deploy-skill --project install into ./.claude/skills/psadt-deploy instead
|
|
7
|
-
* npx psadt-deploy-skill --dir <path> install into an explicit folder
|
|
8
|
-
* npx psadt-deploy-skill --ref <ref> a branch or tag instead of main
|
|
9
|
-
* npx psadt-deploy-skill --no-setup skip the setup doctor
|
|
10
|
-
*
|
|
11
|
-
* Design notes that matter:
|
|
12
|
-
*
|
|
13
|
-
* - Zero dependencies. Node 18 has global fetch, Windows ships bsdtar as tar.exe, and an installer that
|
|
14
|
-
* pulls in a dependency tree is an installer that can break for reasons unrelated to the skill.
|
|
15
|
-
* - The package ships ONLY bin/. The skill itself comes from GitHub at install time, so a new skill
|
|
16
|
-
* version needs no npm republish - only a change to this file does.
|
|
17
|
-
* - This script never writes the skill config. Resolving the config home is Get-PsadtConfig's job
|
|
18
|
-
* (explicit -SkillRoot > $env:PSADT_DEPLOY_HOME > %LOCALAPPDATA%\psadt-deploy), and a second
|
|
19
|
-
* implementation in JavaScript would drift from it. So it spawns Set-PsadtConfig.ps1 and
|
|
20
|
-
* Initialize-PsadtSkill.ps1 and lets them decide.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { spawnSync } from 'node:child_process';
|
|
24
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
|
25
|
-
import { homedir, tmpdir } from 'node:os';
|
|
26
|
-
import { join, resolve } from 'node:path';
|
|
27
|
-
|
|
28
|
-
const REPO = 'pt1987/claude-code-psadt-skill';
|
|
29
|
-
const SKILL_FOLDER = 'psadt-deploy';
|
|
30
|
-
|
|
31
|
-
// --- arguments ---------------------------------------------------------------------------------
|
|
32
|
-
const argv = process.argv.slice(2);
|
|
33
|
-
function flagValue(name) {
|
|
34
|
-
const i = argv.indexOf(name);
|
|
35
|
-
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
|
|
36
|
-
}
|
|
37
|
-
const wantHelp = argv.includes('--help') || argv.includes('-h');
|
|
38
|
-
const ref = flagValue('--ref') || 'main';
|
|
39
|
-
const noSetup = argv.includes('--no-setup');
|
|
40
|
-
const explicitDir = flagValue('--dir');
|
|
41
|
-
const projectScope = argv.includes('--project');
|
|
42
|
-
|
|
43
|
-
if (wantHelp) {
|
|
44
|
-
console.log(`psadt-deploy-skill - install the psadt-deploy Claude Code skill
|
|
45
|
-
|
|
46
|
-
npx psadt-deploy-skill [--dir <path>] [--project] [--ref <branch|tag>] [--no-setup]
|
|
47
|
-
|
|
48
|
-
--dir <path> install into this folder
|
|
49
|
-
--project install into ./.claude/skills/${SKILL_FOLDER} (default is the user's ~/.claude/skills)
|
|
50
|
-
--ref <ref> branch or tag to install (default: main)
|
|
51
|
-
--no-setup do not run the setup doctor afterwards
|
|
52
|
-
`);
|
|
53
|
-
process.exit(0);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const target = explicitDir
|
|
57
|
-
? resolve(explicitDir)
|
|
58
|
-
: projectScope
|
|
59
|
-
? resolve(join(process.cwd(), '.claude', 'skills', SKILL_FOLDER))
|
|
60
|
-
: resolve(join(homedir(), '.claude', 'skills', SKILL_FOLDER));
|
|
61
|
-
|
|
62
|
-
// --- small helpers -----------------------------------------------------------------------------
|
|
63
|
-
function run(exe, args, opts = {}) {
|
|
64
|
-
return spawnSync(exe, args, { stdio: 'inherit', shell: false, ...opts });
|
|
65
|
-
}
|
|
66
|
-
function runCapture(exe, args) {
|
|
67
|
-
return spawnSync(exe, args, { encoding: 'utf8', shell: false });
|
|
68
|
-
}
|
|
69
|
-
function has(exe) {
|
|
70
|
-
const r = runCapture(exe, ['--version']);
|
|
71
|
-
return !r.error && r.status === 0;
|
|
72
|
-
}
|
|
73
|
-
function fail(msg) {
|
|
74
|
-
console.error(`\n ERROR ${msg}\n`);
|
|
75
|
-
process.exit(1);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (process.platform !== 'win32') {
|
|
79
|
-
fail('This skill builds Windows Intune packages and needs Windows (PowerShell, IntuneWinAppUtil, PSADT).');
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
console.log(`\npsadt-deploy-skill\n target : ${target}\n ref : ${ref}`);
|
|
83
|
-
|
|
84
|
-
// --- 1. get the skill --------------------------------------------------------------------------
|
|
85
|
-
// Order of preference: update an existing clone, else clone, else tarball. The tarball route exists
|
|
86
|
-
// because plenty of managed machines have no git at all.
|
|
87
|
-
const gitAvailable = has('git');
|
|
88
|
-
mkdirSync(target, { recursive: true });
|
|
89
|
-
|
|
90
|
-
if (existsSync(join(target, '.git'))) {
|
|
91
|
-
if (!gitAvailable) fail(`${target} is a git clone but git is not available to update it.`);
|
|
92
|
-
console.log('\n updating the existing clone (git pull --ff-only)');
|
|
93
|
-
const r = run('git', ['-C', target, 'pull', '--ff-only', 'origin', ref]);
|
|
94
|
-
if (r.status !== 0) {
|
|
95
|
-
fail('git pull --ff-only failed. Local changes in the skill folder? Commit or discard them, or install into a fresh folder with --dir.');
|
|
96
|
-
}
|
|
97
|
-
} else if (gitAvailable) {
|
|
98
|
-
console.log('\n cloning (git clone --depth 1)');
|
|
99
|
-
// Clone into a temp folder and move the contents, because git refuses a non-empty target.
|
|
100
|
-
const stage = join(tmpdir(), `psadt-skill-${Date.now()}`);
|
|
101
|
-
const r = run('git', ['clone', '--depth', '1', '--branch', ref, `https://github.com/${REPO}.git`, stage]);
|
|
102
|
-
if (r.status !== 0) {
|
|
103
|
-
fail(
|
|
104
|
-
`git clone failed. If ${REPO} is private you need access to it: sign in with the GitHub CLI (gh auth login), ` +
|
|
105
|
-
'configure a credential helper, or clone it manually and point this installer at the folder with --dir.'
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
// robocopy is on every Windows box and handles the merge without a shell. 0-7 are success codes.
|
|
109
|
-
const c = runCapture('robocopy', [stage, target, '/E', '/NFL', '/NDL', '/NJH', '/NJS', '/NP']);
|
|
110
|
-
rmSync(stage, { recursive: true, force: true });
|
|
111
|
-
if ((c.status ?? 8) > 7) fail('Copying the clone into the target folder failed.');
|
|
112
|
-
} else {
|
|
113
|
-
console.log('\n no git found - downloading the tarball');
|
|
114
|
-
const url = `https://codeload.github.com/${REPO}/tar.gz/refs/heads/${ref}`;
|
|
115
|
-
const tgz = join(tmpdir(), `psadt-skill-${Date.now()}.tar.gz`);
|
|
116
|
-
try {
|
|
117
|
-
const res = await fetch(url);
|
|
118
|
-
if (res.status === 404) {
|
|
119
|
-
// GitHub answers 404 - not 403 - for a private repository you cannot see. Without git there is no
|
|
120
|
-
// way to authenticate here, so say what is actually wrong instead of "download failed".
|
|
121
|
-
fail(
|
|
122
|
-
`${REPO} is not publicly readable (HTTP 404), and without git this installer cannot authenticate. ` +
|
|
123
|
-
'Either install git and sign in to GitHub, or clone the repository manually and re-run with ' +
|
|
124
|
-
'--dir <folder>.'
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
|
128
|
-
writeFileSync(tgz, Buffer.from(await res.arrayBuffer()));
|
|
129
|
-
} catch (e) {
|
|
130
|
-
fail(`Download failed: ${e.message}`);
|
|
131
|
-
}
|
|
132
|
-
// The archive's top-level directory is <repo>-<ref>/, so strip exactly one component.
|
|
133
|
-
const r = run('tar', ['-xzf', tgz, '-C', target, '--strip-components=1']);
|
|
134
|
-
rmSync(tgz, { force: true });
|
|
135
|
-
if (r.status !== 0) fail('Extracting the tarball failed (tar.exe is expected in C:\\Windows\\System32).');
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (!existsSync(join(target, 'SKILL.md'))) {
|
|
139
|
-
fail(`${target} has no SKILL.md - the download did not produce a skill folder.`);
|
|
140
|
-
}
|
|
141
|
-
console.log(' OK skill files in place');
|
|
142
|
-
|
|
143
|
-
// --- 2. PowerShell ------------------------------------------------------------------------------
|
|
144
|
-
// pwsh first; powershell.exe as the fallback. -ExecutionPolicy Bypass on BOTH: 5.1 defaults to
|
|
145
|
-
// Restricted, and a GPO can pin pwsh to AllSigned - either way an unsigned script would not run.
|
|
146
|
-
const psExe = has('pwsh') ? 'pwsh' : 'powershell';
|
|
147
|
-
const psBase = ['-NoProfile', '-ExecutionPolicy', 'Bypass'];
|
|
148
|
-
|
|
149
|
-
// --- 3. record the commit (via the script that owns the config, not from here) -------------------
|
|
150
|
-
try {
|
|
151
|
-
const res = await fetch(`https://api.github.com/repos/${REPO}/commits/${ref}`, {
|
|
152
|
-
headers: { 'User-Agent': 'psadt-deploy-skill', Accept: 'application/vnd.github+json' },
|
|
153
|
-
});
|
|
154
|
-
if (res.ok) {
|
|
155
|
-
const sha = (await res.json()).sha;
|
|
156
|
-
if (sha) {
|
|
157
|
-
const setCfg = join(target, 'scripts', 'Set-PsadtConfig.ps1');
|
|
158
|
-
// -Command, not -File: -File passes every argument as a string and -Updates wants a hashtable.
|
|
159
|
-
run(psExe, [...psBase, '-Command', `& '${setCfg}' -Updates @{'tooling.skillCommit'='${sha}'}`]);
|
|
160
|
-
console.log(` OK recorded commit ${sha.slice(0, 7)}`);
|
|
161
|
-
}
|
|
162
|
-
} else if (res.status === 404) {
|
|
163
|
-
// 404 on a repo you just cloned = the repo is private and this call is unauthenticated. Worth saying
|
|
164
|
-
// once: the skill works, only the sha-based update check falls back to comparing versions.
|
|
165
|
-
console.log(` -- ${REPO} is not publicly readable, so the commit sha was not recorded.`);
|
|
166
|
-
console.log(' The skill works; "update skill" compares CHANGELOG versions instead of shas.');
|
|
167
|
-
} else {
|
|
168
|
-
console.log(` -- could not read the commit sha (HTTP ${res.status}) - not fatal`);
|
|
169
|
-
}
|
|
170
|
-
} catch {
|
|
171
|
-
// A missing commit sha only means the next update check compares versions instead of shas.
|
|
172
|
-
console.log(' -- could not record the commit sha (offline?) - not fatal');
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// --- 4. the setup doctor ------------------------------------------------------------------------
|
|
176
|
-
if (noSetup) {
|
|
177
|
-
console.log('\n skipped the setup doctor (--no-setup). Run it later:');
|
|
178
|
-
console.log(` ${psExe} -File "${join(target, 'scripts', 'Initialize-PsadtSkill.ps1')}" -Fix\n`);
|
|
179
|
-
process.exit(0);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const doctor = join(target, 'scripts', 'Initialize-PsadtSkill.ps1');
|
|
183
|
-
const jsonPath = join(tmpdir(), `psadt-doctor-${Date.now()}.json`);
|
|
184
|
-
console.log('\n running the setup doctor (-Fix)\n');
|
|
185
|
-
// -JsonPath rather than -Json: stdout is inherited so the user sees the doctor's own table live, and the
|
|
186
|
-
// machine-readable copy comes back through the file.
|
|
187
|
-
// -Command with Out-Null rather than -File: the doctor also RETURNS its result object, and with inherited
|
|
188
|
-
// stdout that object gets dumped underneath its own table. Out-Null drops the object; Write-Host output
|
|
189
|
-
// (the table) is unaffected.
|
|
190
|
-
const d = run(psExe, [...psBase, '-Command', `& '${doctor}' -Fix -JsonPath '${jsonPath}' | Out-Null`]);
|
|
191
|
-
|
|
192
|
-
let verdict = null;
|
|
193
|
-
try {
|
|
194
|
-
if (existsSync(jsonPath)) verdict = JSON.parse(readFileSync(jsonPath, 'utf8'));
|
|
195
|
-
} catch {
|
|
196
|
-
/* the doctor printed its table anyway */
|
|
197
|
-
}
|
|
198
|
-
rmSync(jsonPath, { force: true });
|
|
199
|
-
|
|
200
|
-
if (verdict) {
|
|
201
|
-
console.log(`\n setup: ${verdict.Overall}`);
|
|
202
|
-
const missing = Array.isArray(verdict.Missing) ? verdict.Missing : [];
|
|
203
|
-
if (missing.length) {
|
|
204
|
-
console.log(`\n ${missing.length} value(s) still need you: ${missing.join(', ')}`);
|
|
205
|
-
console.log(" Open Claude Code in a package folder and say: \"psadt setup\"");
|
|
206
|
-
} else if (verdict.Overall === 'GREEN') {
|
|
207
|
-
console.log(' Ready. Open Claude Code and say what you want packaged.');
|
|
208
|
-
}
|
|
209
|
-
} else if (d.status !== 0) {
|
|
210
|
-
console.log('\n The setup doctor did not complete. Run it manually:');
|
|
211
|
-
console.log(` ${psExe} -File "${doctor}" -Fix`);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
console.log('');
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* psadt-deploy-skill - installer for the psadt-deploy Claude Code skill.
|
|
4
|
+
*
|
|
5
|
+
* npx psadt-deploy-skill install/update in ~/.claude/skills/psadt-deploy
|
|
6
|
+
* npx psadt-deploy-skill --project install into ./.claude/skills/psadt-deploy instead
|
|
7
|
+
* npx psadt-deploy-skill --dir <path> install into an explicit folder
|
|
8
|
+
* npx psadt-deploy-skill --ref <ref> a branch or tag instead of main
|
|
9
|
+
* npx psadt-deploy-skill --no-setup skip the setup doctor
|
|
10
|
+
*
|
|
11
|
+
* Design notes that matter:
|
|
12
|
+
*
|
|
13
|
+
* - Zero dependencies. Node 18 has global fetch, Windows ships bsdtar as tar.exe, and an installer that
|
|
14
|
+
* pulls in a dependency tree is an installer that can break for reasons unrelated to the skill.
|
|
15
|
+
* - The package ships ONLY bin/. The skill itself comes from GitHub at install time, so a new skill
|
|
16
|
+
* version needs no npm republish - only a change to this file does.
|
|
17
|
+
* - This script never writes the skill config. Resolving the config home is Get-PsadtConfig's job
|
|
18
|
+
* (explicit -SkillRoot > $env:PSADT_DEPLOY_HOME > %LOCALAPPDATA%\psadt-deploy), and a second
|
|
19
|
+
* implementation in JavaScript would drift from it. So it spawns Set-PsadtConfig.ps1 and
|
|
20
|
+
* Initialize-PsadtSkill.ps1 and lets them decide.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { homedir, tmpdir } from 'node:os';
|
|
26
|
+
import { join, resolve } from 'node:path';
|
|
27
|
+
|
|
28
|
+
const REPO = 'pt1987/claude-code-psadt-skill';
|
|
29
|
+
const SKILL_FOLDER = 'psadt-deploy';
|
|
30
|
+
|
|
31
|
+
// --- arguments ---------------------------------------------------------------------------------
|
|
32
|
+
const argv = process.argv.slice(2);
|
|
33
|
+
function flagValue(name) {
|
|
34
|
+
const i = argv.indexOf(name);
|
|
35
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
|
|
36
|
+
}
|
|
37
|
+
const wantHelp = argv.includes('--help') || argv.includes('-h');
|
|
38
|
+
const ref = flagValue('--ref') || 'main';
|
|
39
|
+
const noSetup = argv.includes('--no-setup');
|
|
40
|
+
const explicitDir = flagValue('--dir');
|
|
41
|
+
const projectScope = argv.includes('--project');
|
|
42
|
+
|
|
43
|
+
if (wantHelp) {
|
|
44
|
+
console.log(`psadt-deploy-skill - install the psadt-deploy Claude Code skill
|
|
45
|
+
|
|
46
|
+
npx psadt-deploy-skill [--dir <path>] [--project] [--ref <branch|tag>] [--no-setup]
|
|
47
|
+
|
|
48
|
+
--dir <path> install into this folder
|
|
49
|
+
--project install into ./.claude/skills/${SKILL_FOLDER} (default is the user's ~/.claude/skills)
|
|
50
|
+
--ref <ref> branch or tag to install (default: main)
|
|
51
|
+
--no-setup do not run the setup doctor afterwards
|
|
52
|
+
`);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const target = explicitDir
|
|
57
|
+
? resolve(explicitDir)
|
|
58
|
+
: projectScope
|
|
59
|
+
? resolve(join(process.cwd(), '.claude', 'skills', SKILL_FOLDER))
|
|
60
|
+
: resolve(join(homedir(), '.claude', 'skills', SKILL_FOLDER));
|
|
61
|
+
|
|
62
|
+
// --- small helpers -----------------------------------------------------------------------------
|
|
63
|
+
function run(exe, args, opts = {}) {
|
|
64
|
+
return spawnSync(exe, args, { stdio: 'inherit', shell: false, ...opts });
|
|
65
|
+
}
|
|
66
|
+
function runCapture(exe, args) {
|
|
67
|
+
return spawnSync(exe, args, { encoding: 'utf8', shell: false });
|
|
68
|
+
}
|
|
69
|
+
function has(exe) {
|
|
70
|
+
const r = runCapture(exe, ['--version']);
|
|
71
|
+
return !r.error && r.status === 0;
|
|
72
|
+
}
|
|
73
|
+
function fail(msg) {
|
|
74
|
+
console.error(`\n ERROR ${msg}\n`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (process.platform !== 'win32') {
|
|
79
|
+
fail('This skill builds Windows Intune packages and needs Windows (PowerShell, IntuneWinAppUtil, PSADT).');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(`\npsadt-deploy-skill\n target : ${target}\n ref : ${ref}`);
|
|
83
|
+
|
|
84
|
+
// --- 1. get the skill --------------------------------------------------------------------------
|
|
85
|
+
// Order of preference: update an existing clone, else clone, else tarball. The tarball route exists
|
|
86
|
+
// because plenty of managed machines have no git at all.
|
|
87
|
+
const gitAvailable = has('git');
|
|
88
|
+
mkdirSync(target, { recursive: true });
|
|
89
|
+
|
|
90
|
+
if (existsSync(join(target, '.git'))) {
|
|
91
|
+
if (!gitAvailable) fail(`${target} is a git clone but git is not available to update it.`);
|
|
92
|
+
console.log('\n updating the existing clone (git pull --ff-only)');
|
|
93
|
+
const r = run('git', ['-C', target, 'pull', '--ff-only', 'origin', ref]);
|
|
94
|
+
if (r.status !== 0) {
|
|
95
|
+
fail('git pull --ff-only failed. Local changes in the skill folder? Commit or discard them, or install into a fresh folder with --dir.');
|
|
96
|
+
}
|
|
97
|
+
} else if (gitAvailable) {
|
|
98
|
+
console.log('\n cloning (git clone --depth 1)');
|
|
99
|
+
// Clone into a temp folder and move the contents, because git refuses a non-empty target.
|
|
100
|
+
const stage = join(tmpdir(), `psadt-skill-${Date.now()}`);
|
|
101
|
+
const r = run('git', ['clone', '--depth', '1', '--branch', ref, `https://github.com/${REPO}.git`, stage]);
|
|
102
|
+
if (r.status !== 0) {
|
|
103
|
+
fail(
|
|
104
|
+
`git clone failed. If ${REPO} is private you need access to it: sign in with the GitHub CLI (gh auth login), ` +
|
|
105
|
+
'configure a credential helper, or clone it manually and point this installer at the folder with --dir.'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
// robocopy is on every Windows box and handles the merge without a shell. 0-7 are success codes.
|
|
109
|
+
const c = runCapture('robocopy', [stage, target, '/E', '/NFL', '/NDL', '/NJH', '/NJS', '/NP']);
|
|
110
|
+
rmSync(stage, { recursive: true, force: true });
|
|
111
|
+
if ((c.status ?? 8) > 7) fail('Copying the clone into the target folder failed.');
|
|
112
|
+
} else {
|
|
113
|
+
console.log('\n no git found - downloading the tarball');
|
|
114
|
+
const url = `https://codeload.github.com/${REPO}/tar.gz/refs/heads/${ref}`;
|
|
115
|
+
const tgz = join(tmpdir(), `psadt-skill-${Date.now()}.tar.gz`);
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetch(url);
|
|
118
|
+
if (res.status === 404) {
|
|
119
|
+
// GitHub answers 404 - not 403 - for a private repository you cannot see. Without git there is no
|
|
120
|
+
// way to authenticate here, so say what is actually wrong instead of "download failed".
|
|
121
|
+
fail(
|
|
122
|
+
`${REPO} is not publicly readable (HTTP 404), and without git this installer cannot authenticate. ` +
|
|
123
|
+
'Either install git and sign in to GitHub, or clone the repository manually and re-run with ' +
|
|
124
|
+
'--dir <folder>.'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
|
128
|
+
writeFileSync(tgz, Buffer.from(await res.arrayBuffer()));
|
|
129
|
+
} catch (e) {
|
|
130
|
+
fail(`Download failed: ${e.message}`);
|
|
131
|
+
}
|
|
132
|
+
// The archive's top-level directory is <repo>-<ref>/, so strip exactly one component.
|
|
133
|
+
const r = run('tar', ['-xzf', tgz, '-C', target, '--strip-components=1']);
|
|
134
|
+
rmSync(tgz, { force: true });
|
|
135
|
+
if (r.status !== 0) fail('Extracting the tarball failed (tar.exe is expected in C:\\Windows\\System32).');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!existsSync(join(target, 'SKILL.md'))) {
|
|
139
|
+
fail(`${target} has no SKILL.md - the download did not produce a skill folder.`);
|
|
140
|
+
}
|
|
141
|
+
console.log(' OK skill files in place');
|
|
142
|
+
|
|
143
|
+
// --- 2. PowerShell ------------------------------------------------------------------------------
|
|
144
|
+
// pwsh first; powershell.exe as the fallback. -ExecutionPolicy Bypass on BOTH: 5.1 defaults to
|
|
145
|
+
// Restricted, and a GPO can pin pwsh to AllSigned - either way an unsigned script would not run.
|
|
146
|
+
const psExe = has('pwsh') ? 'pwsh' : 'powershell';
|
|
147
|
+
const psBase = ['-NoProfile', '-ExecutionPolicy', 'Bypass'];
|
|
148
|
+
|
|
149
|
+
// --- 3. record the commit (via the script that owns the config, not from here) -------------------
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetch(`https://api.github.com/repos/${REPO}/commits/${ref}`, {
|
|
152
|
+
headers: { 'User-Agent': 'psadt-deploy-skill', Accept: 'application/vnd.github+json' },
|
|
153
|
+
});
|
|
154
|
+
if (res.ok) {
|
|
155
|
+
const sha = (await res.json()).sha;
|
|
156
|
+
if (sha) {
|
|
157
|
+
const setCfg = join(target, 'scripts', 'Set-PsadtConfig.ps1');
|
|
158
|
+
// -Command, not -File: -File passes every argument as a string and -Updates wants a hashtable.
|
|
159
|
+
run(psExe, [...psBase, '-Command', `& '${setCfg}' -Updates @{'tooling.skillCommit'='${sha}'}`]);
|
|
160
|
+
console.log(` OK recorded commit ${sha.slice(0, 7)}`);
|
|
161
|
+
}
|
|
162
|
+
} else if (res.status === 404) {
|
|
163
|
+
// 404 on a repo you just cloned = the repo is private and this call is unauthenticated. Worth saying
|
|
164
|
+
// once: the skill works, only the sha-based update check falls back to comparing versions.
|
|
165
|
+
console.log(` -- ${REPO} is not publicly readable, so the commit sha was not recorded.`);
|
|
166
|
+
console.log(' The skill works; "update skill" compares CHANGELOG versions instead of shas.');
|
|
167
|
+
} else {
|
|
168
|
+
console.log(` -- could not read the commit sha (HTTP ${res.status}) - not fatal`);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// A missing commit sha only means the next update check compares versions instead of shas.
|
|
172
|
+
console.log(' -- could not record the commit sha (offline?) - not fatal');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- 4. the setup doctor ------------------------------------------------------------------------
|
|
176
|
+
if (noSetup) {
|
|
177
|
+
console.log('\n skipped the setup doctor (--no-setup). Run it later:');
|
|
178
|
+
console.log(` ${psExe} -File "${join(target, 'scripts', 'Initialize-PsadtSkill.ps1')}" -Fix\n`);
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const doctor = join(target, 'scripts', 'Initialize-PsadtSkill.ps1');
|
|
183
|
+
const jsonPath = join(tmpdir(), `psadt-doctor-${Date.now()}.json`);
|
|
184
|
+
console.log('\n running the setup doctor (-Fix)\n');
|
|
185
|
+
// -JsonPath rather than -Json: stdout is inherited so the user sees the doctor's own table live, and the
|
|
186
|
+
// machine-readable copy comes back through the file.
|
|
187
|
+
// -Command with Out-Null rather than -File: the doctor also RETURNS its result object, and with inherited
|
|
188
|
+
// stdout that object gets dumped underneath its own table. Out-Null drops the object; Write-Host output
|
|
189
|
+
// (the table) is unaffected.
|
|
190
|
+
const d = run(psExe, [...psBase, '-Command', `& '${doctor}' -Fix -JsonPath '${jsonPath}' | Out-Null`]);
|
|
191
|
+
|
|
192
|
+
let verdict = null;
|
|
193
|
+
try {
|
|
194
|
+
if (existsSync(jsonPath)) verdict = JSON.parse(readFileSync(jsonPath, 'utf8'));
|
|
195
|
+
} catch {
|
|
196
|
+
/* the doctor printed its table anyway */
|
|
197
|
+
}
|
|
198
|
+
rmSync(jsonPath, { force: true });
|
|
199
|
+
|
|
200
|
+
if (verdict) {
|
|
201
|
+
console.log(`\n setup: ${verdict.Overall}`);
|
|
202
|
+
const missing = Array.isArray(verdict.Missing) ? verdict.Missing : [];
|
|
203
|
+
if (missing.length) {
|
|
204
|
+
console.log(`\n ${missing.length} value(s) still need you: ${missing.join(', ')}`);
|
|
205
|
+
console.log(" Open Claude Code in a package folder and say: \"psadt setup\"");
|
|
206
|
+
} else if (verdict.Overall === 'GREEN') {
|
|
207
|
+
console.log(' Ready. Open Claude Code and say what you want packaged.');
|
|
208
|
+
}
|
|
209
|
+
} else if (d.status !== 0) {
|
|
210
|
+
console.log('\n The setup doctor did not complete. Run it manually:');
|
|
211
|
+
console.log(` ${psExe} -File "${doctor}" -Fix`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.log('');
|