psadt-deploy-skill 0.23.0 → 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.
Files changed (4) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +50 -2
  3. package/bin/install.mjs +214 -214
  4. 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
@@ -39,7 +39,7 @@ That installs the skill into `~/.claude/skills/psadt-deploy` and runs the setup
39
39
  everything it can and names the handful of values only you can supply (see
40
40
  [First-run setup](#first-run-setup)). Then open Claude Code in any folder and say what you want:
41
41
 
42
- > *"Baue mir ein Intune-Paket für 7-Zip 24.09"* — or *"package Notepad++ for Intune"*
42
+ > *"Create the Win32 Intune package for 7-Zip 24.09"* — or *"package Notepad++ for Intune"*
43
43
 
44
44
  The skill asks at most **four decision gates** (scope · deployment semantics · SYSTEM-test consent ·
45
45
  upload confirmation). Everything else it researches and states as an assumption instead of asking.
@@ -217,7 +217,8 @@ pwsh "$env:USERPROFILE\.claude\skills\psadt-deploy\scripts\Initialize-PsadtSkill
217
217
 
218
218
  `npx skills add pt1987/claude-code-psadt-skill` works too, since `SKILL.md` sits in the repository root.
219
219
 
220
- > Needs read access to this repository: the installer clones over your existing git credentials.
220
+ No git on the machine? The installer falls back to the branch tarball and Windows' own `tar.exe`, so the
221
+ one-liner still works.
221
222
 
222
223
  The skill activates automatically when you ask Claude Code to build an Intune package, or when you work in
223
224
  a folder containing `Invoke-AppDeployToolkit.ps1`.
@@ -368,6 +369,53 @@ pre-flight fails on non-ASCII without a BOM), and anything that lands in a packa
368
369
  Recent releases below; the complete history is in **[CHANGELOG.md](CHANGELOG.md)** (nothing is ever removed
369
370
  from either).
370
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
+
411
+ ### 0.23.1 - 04.09.2026
412
+ - **Fixed: two generators were broken since 0.21.0.** `New-BrowserExtensionPackage.ps1` and
413
+ `New-WindowsFeaturePackage.ps1` died at run time — the per-run `LogName` change had wedged a statement
414
+ into the template `Replace(...)` chain, which PowerShell parses as a property assignment with an empty
415
+ name. Valid syntax, so neither the parse check nor the source-inspecting tests could see it; it surfaced
416
+ the first time the whole chain ran against a real package. A guard in all four generator test suites now
417
+ fails on a broken chain, and the guard itself was verified against a broken and a good sample.
418
+
371
419
  ### 0.23.0 - 04.09.2026
372
420
  - **`npx psadt-deploy-skill`.** One line installs or updates the skill into `~/.claude/skills/psadt-deploy`
373
421
  and runs the setup doctor. Flags `--dir`, `--project`, `--ref`, `--no-setup`. Cloning to exactly the right
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('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "psadt-deploy-skill",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Installer for the psadt-deploy Claude Code skill: build, test and deploy PSADT v4.x Intune Win32 packages.",
5
5
  "keywords": [
6
6
  "psadt",