win-nice 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +356 -0
- package/bin/abovenormal +11 -0
- package/bin/abovenormal.bat +13 -0
- package/bin/abovenormal.ps1 +193 -0
- package/bin/admin +11 -0
- package/bin/admin.bat +12 -0
- package/bin/admin.ps1 +259 -0
- package/bin/belownormal +11 -0
- package/bin/belownormal.bat +13 -0
- package/bin/belownormal.ps1 +192 -0
- package/bin/cap +11 -0
- package/bin/cap.bat +10 -0
- package/bin/cap.ps1 +269 -0
- package/bin/cx +11 -0
- package/bin/cx.bat +8 -0
- package/bin/cx.ps1 +185 -0
- package/bin/cy +11 -0
- package/bin/cy.bat +8 -0
- package/bin/cy.ps1 +185 -0
- package/bin/high +11 -0
- package/bin/high.bat +12 -0
- package/bin/high.ps1 +193 -0
- package/bin/idle +11 -0
- package/bin/idle.bat +12 -0
- package/bin/idle.ps1 +192 -0
- package/bin/pint +11 -0
- package/bin/pint.bat +8 -0
- package/bin/pint.ps1 +270 -0
- package/bin/realtime +11 -0
- package/bin/realtime.bat +13 -0
- package/bin/realtime.ps1 +197 -0
- package/bin/uiup +11 -0
- package/bin/uiup.bat +4 -0
- package/bin/uiup.ps1 +41 -0
- package/install/cli.js +62 -0
- package/install/install.js +79 -0
- package/install/manifest.js +29 -0
- package/install/paths.js +141 -0
- package/install/skill.js +68 -0
- package/install/uninstall.js +76 -0
- package/package.json +48 -0
- package/skills/win-nice/SKILL.md +132 -0
package/bin/realtime.ps1
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT OR Apache-2.0
|
|
2
|
+
# win-nice: managed-file
|
|
3
|
+
# DANGEROUS: REALTIME_PRIORITY_CLASS outranks the OS's own input/audio/UI threads.
|
|
4
|
+
# A busy realtime-priority process can make the desktop (mouse, keyboard, everything)
|
|
5
|
+
# stop responding - the exact failure mode this whole project exists to prevent. See
|
|
6
|
+
# README before using this.
|
|
7
|
+
# No param(): nothing here needs a named parameter, and $args sidesteps
|
|
8
|
+
# PowerShell's parameter binder entirely - see cap.ps1 for why that matters.
|
|
9
|
+
$Command = $args
|
|
10
|
+
|
|
11
|
+
if (-not $Command -or $Command.Count -eq 0) {
|
|
12
|
+
Write-Error "usage: realtime <command> [args...]"
|
|
13
|
+
exit 1
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# Fallback command line for when the target isn't a directly-launchable .exe (see
|
|
17
|
+
# RealtimeLauncher.Run below) - re-parsed by cmd.exe (via "cmd.exe /c"), so quoting must
|
|
18
|
+
# neutralize its operators (&|<>^) and not just whitespace - see cap.ps1 for the
|
|
19
|
+
# same logic and its documented "%" limitation. realtime.bat has its own, more
|
|
20
|
+
# severe "%" caveat (see there) that applies before this script ever runs.
|
|
21
|
+
$commandLine = ($Command | ForEach-Object {
|
|
22
|
+
$escaped = $_ -replace '"', '\"'
|
|
23
|
+
if ($escaped -eq '' -or $escaped -match '[\s"&|<>^]') { '"' + $escaped + '"' } else { $escaped }
|
|
24
|
+
}) -join ' '
|
|
25
|
+
|
|
26
|
+
$source = @"
|
|
27
|
+
using System;
|
|
28
|
+
using System.Runtime.InteropServices;
|
|
29
|
+
using System.Text;
|
|
30
|
+
|
|
31
|
+
public static class RealtimeLauncher
|
|
32
|
+
{
|
|
33
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
34
|
+
struct STARTUPINFO
|
|
35
|
+
{
|
|
36
|
+
public int cb;
|
|
37
|
+
public string lpReserved;
|
|
38
|
+
public string lpDesktop;
|
|
39
|
+
public string lpTitle;
|
|
40
|
+
public int dwX;
|
|
41
|
+
public int dwY;
|
|
42
|
+
public int dwXSize;
|
|
43
|
+
public int dwYSize;
|
|
44
|
+
public int dwXCountChars;
|
|
45
|
+
public int dwYCountChars;
|
|
46
|
+
public int dwFillAttribute;
|
|
47
|
+
public int dwFlags;
|
|
48
|
+
public short wShowWindow;
|
|
49
|
+
public short cbReserved2;
|
|
50
|
+
public IntPtr lpReserved2;
|
|
51
|
+
public IntPtr hStdInput;
|
|
52
|
+
public IntPtr hStdOutput;
|
|
53
|
+
public IntPtr hStdError;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
57
|
+
struct PROCESS_INFORMATION
|
|
58
|
+
{
|
|
59
|
+
public IntPtr hProcess;
|
|
60
|
+
public IntPtr hThread;
|
|
61
|
+
public int dwProcessId;
|
|
62
|
+
public int dwThreadId;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
66
|
+
static extern bool CreateProcess(string lpApplicationName, StringBuilder lpCommandLine,
|
|
67
|
+
IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles,
|
|
68
|
+
uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory,
|
|
69
|
+
ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
|
|
70
|
+
|
|
71
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
72
|
+
static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
|
|
73
|
+
|
|
74
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
75
|
+
static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
|
|
76
|
+
|
|
77
|
+
[DllImport("kernel32.dll")]
|
|
78
|
+
static extern bool CloseHandle(IntPtr hObject);
|
|
79
|
+
|
|
80
|
+
// Standard MSVCRT/CommandLineToArgvW quoting: safe for a directly-launched .exe's
|
|
81
|
+
// own argv parsing. No cmd.exe involved on this path, so none of its operator or
|
|
82
|
+
// "%" expansion semantics apply - this is the safe path, used whenever possible.
|
|
83
|
+
static string ArgvQuote(string arg)
|
|
84
|
+
{
|
|
85
|
+
if (arg.Length > 0 && arg.IndexOfAny(new char[] { ' ', '\t', '\n', '\v', '"' }) < 0)
|
|
86
|
+
return arg;
|
|
87
|
+
|
|
88
|
+
var result = new StringBuilder();
|
|
89
|
+
result.Append('"');
|
|
90
|
+
int backslashes = 0;
|
|
91
|
+
foreach (char c in arg)
|
|
92
|
+
{
|
|
93
|
+
if (c == '\\')
|
|
94
|
+
{
|
|
95
|
+
backslashes++;
|
|
96
|
+
}
|
|
97
|
+
else if (c == '"')
|
|
98
|
+
{
|
|
99
|
+
result.Append('\\', backslashes * 2 + 1);
|
|
100
|
+
result.Append('"');
|
|
101
|
+
backslashes = 0;
|
|
102
|
+
}
|
|
103
|
+
else
|
|
104
|
+
{
|
|
105
|
+
if (backslashes > 0) { result.Append('\\', backslashes); backslashes = 0; }
|
|
106
|
+
result.Append(c);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (backslashes > 0) result.Append('\\', backslashes * 2);
|
|
110
|
+
result.Append('"');
|
|
111
|
+
return result.ToString();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
static string BuildArgvCommandLine(string[] argv)
|
|
115
|
+
{
|
|
116
|
+
var parts = new string[argv.Length];
|
|
117
|
+
for (int i = 0; i < argv.Length; i++) parts[i] = ArgvQuote(argv[i]);
|
|
118
|
+
return string.Join(" ", parts);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Priority is passed via dwCreationFlags, applied atomically at creation - no
|
|
122
|
+
// Job Object needed. Windows' CreateProcess inherits IDLE/BELOW_NORMAL priority
|
|
123
|
+
// by default to children that don't request a priority of their own; REALTIME is
|
|
124
|
+
// NOT inherited by default (see README).
|
|
125
|
+
public static int Run(uint priorityClass, string[] argv, string cmdExeCommandLine)
|
|
126
|
+
{
|
|
127
|
+
var si = new STARTUPINFO();
|
|
128
|
+
si.cb = Marshal.SizeOf(si);
|
|
129
|
+
PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
|
|
130
|
+
|
|
131
|
+
// See cap.ps1 for why .bat/.cmd targets skip the direct attempt entirely:
|
|
132
|
+
// CreateProcess silently re-invokes them through cmd.exe on its own, using
|
|
133
|
+
// unescaped text, instead of failing the way a genuinely missing exe would.
|
|
134
|
+
bool isBatOrCmd = argv.Length > 0 && (
|
|
135
|
+
argv[0].EndsWith(".bat", StringComparison.OrdinalIgnoreCase) ||
|
|
136
|
+
argv[0].EndsWith(".cmd", StringComparison.OrdinalIgnoreCase));
|
|
137
|
+
|
|
138
|
+
bool created = false;
|
|
139
|
+
if (!isBatOrCmd)
|
|
140
|
+
{
|
|
141
|
+
var directCommandLine = new StringBuilder(BuildArgvCommandLine(argv));
|
|
142
|
+
created = CreateProcess(null, directCommandLine, IntPtr.Zero, IntPtr.Zero, true,
|
|
143
|
+
priorityClass, IntPtr.Zero, null, ref si, out pi);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!created)
|
|
147
|
+
{
|
|
148
|
+
// Falling back to cmd.exe /c: a literal "%" in any argument could now
|
|
149
|
+
// trigger environment-variable expansion (cmd.exe pairs up "%" characters
|
|
150
|
+
// across the whole command line, even across separate arguments) and
|
|
151
|
+
// change what actually runs. Fail loudly here instead of silently risking
|
|
152
|
+
// that - there's no reliable per-character escape for "%" at this level.
|
|
153
|
+
foreach (var a in argv)
|
|
154
|
+
{
|
|
155
|
+
if (a.IndexOf('%') >= 0)
|
|
156
|
+
throw new InvalidOperationException(
|
|
157
|
+
"Refusing to run: argument contains '%' and the target needs the cmd.exe " +
|
|
158
|
+
"fallback (not a directly-launchable .exe), where '%' can trigger unintended " +
|
|
159
|
+
"environment-variable expansion. See README's Argument handling section.");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
string cmdExe = Environment.SystemDirectory + "\\cmd.exe";
|
|
163
|
+
// /d: skip HKCU AutoRun (user-writable registry key). /v:off: disable delayed
|
|
164
|
+
// expansion so "!var!" in an argument can't be expanded. /s plus the extra outer
|
|
165
|
+
// quote pair: cmd's /S rule strips exactly that outer pair and leaves the rest of
|
|
166
|
+
// the string untouched - without /S, cmd strips the first and last quote of the
|
|
167
|
+
// whole line instead, which breaks quoting whenever the target path itself needs
|
|
168
|
+
// quotes AND another argument is also quoted.
|
|
169
|
+
var shellCommandLine = new StringBuilder("\"" + cmdExe + "\" /d /v:off /s /c \"" + cmdExeCommandLine + "\"");
|
|
170
|
+
created = CreateProcess(null, shellCommandLine, IntPtr.Zero, IntPtr.Zero, true,
|
|
171
|
+
priorityClass, IntPtr.Zero, null, ref si, out pi);
|
|
172
|
+
if (!created)
|
|
173
|
+
throw new InvalidOperationException("CreateProcess failed: " + Marshal.GetLastWin32Error());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
WaitForSingleObject(pi.hProcess, 0xFFFFFFFF);
|
|
177
|
+
|
|
178
|
+
uint exitCode;
|
|
179
|
+
GetExitCodeProcess(pi.hProcess, out exitCode);
|
|
180
|
+
|
|
181
|
+
CloseHandle(pi.hThread);
|
|
182
|
+
CloseHandle(pi.hProcess);
|
|
183
|
+
|
|
184
|
+
return (int)exitCode;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
"@
|
|
188
|
+
|
|
189
|
+
Add-Type -TypeDefinition $source -Language CSharp
|
|
190
|
+
|
|
191
|
+
$REALTIME_PRIORITY_CLASS = 0x00000100
|
|
192
|
+
try {
|
|
193
|
+
exit ([RealtimeLauncher]::Run($REALTIME_PRIORITY_CLASS, [string[]]$Command, $commandLine))
|
|
194
|
+
} catch {
|
|
195
|
+
Write-Error $_.Exception.InnerException.Message
|
|
196
|
+
exit 1
|
|
197
|
+
}
|
package/bin/uiup
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# SPDX-License-Identifier: MIT OR Apache-2.0
|
|
3
|
+
# win-nice: managed-file
|
|
4
|
+
# Git Bash ignores PATHEXT for bare-name resolution; this shim covers that shell.
|
|
5
|
+
# MSYS2_ARG_CONV_EXCL='*' stops Git Bash/MSYS from rewriting user arguments
|
|
6
|
+
# (e.g. /c, /d, C:\...) into Windows paths before the exec; the shim's own
|
|
7
|
+
# .ps1 path is converted explicitly with cygpath -w so -File gets a Windows path.
|
|
8
|
+
MSYS2_ARG_CONV_EXCL='*'
|
|
9
|
+
export MSYS2_ARG_CONV_EXCL
|
|
10
|
+
script=$(cygpath -w "$(dirname "$0")/uiup.ps1" 2>/dev/null) || script="$(dirname "$0")/uiup.ps1"
|
|
11
|
+
exec powershell -NoProfile -ExecutionPolicy Bypass -File "$script" "$@"
|
package/bin/uiup.bat
ADDED
package/bin/uiup.ps1
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT OR Apache-2.0
|
|
2
|
+
# win-nice: managed-file
|
|
3
|
+
param([switch]$SelfElevated)
|
|
4
|
+
|
|
5
|
+
$targets = @('explorer', 'dwm', 'sihost', 'ShellExperienceHost', 'StartMenuExperienceHost', 'StartMenu', 'SearchApp', 'audiodg')
|
|
6
|
+
|
|
7
|
+
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
8
|
+
if (-not $isAdmin) {
|
|
9
|
+
Write-Host "Not elevated - requesting admin rights (dwm/sihost run under a different account)..."
|
|
10
|
+
try {
|
|
11
|
+
$p = Start-Process powershell -Verb RunAs -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"", '-SelfElevated') -Wait -PassThru
|
|
12
|
+
exit $p.ExitCode
|
|
13
|
+
} catch {
|
|
14
|
+
Write-Error "Elevation was cancelled or failed: $($_.Exception.Message)"
|
|
15
|
+
exit 1
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
$rows = foreach ($name in $targets) {
|
|
20
|
+
$procs = Get-Process -Name $name -ErrorAction SilentlyContinue
|
|
21
|
+
if (-not $procs) {
|
|
22
|
+
[PSCustomObject]@{ Name = $name; Id = '-'; Old = '-'; New = 'not running' }
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
foreach ($proc in $procs) {
|
|
26
|
+
$old = $proc.PriorityClass
|
|
27
|
+
try {
|
|
28
|
+
$proc.PriorityClass = 'High'
|
|
29
|
+
[PSCustomObject]@{ Name = $name; Id = $proc.Id; Old = $old; New = 'High' }
|
|
30
|
+
} catch {
|
|
31
|
+
[PSCustomObject]@{ Name = $name; Id = $proc.Id; Old = $old; New = "FAILED: $($_.Exception.Message)" }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
$rows | Format-Table -AutoSize
|
|
37
|
+
if ($SelfElevated) {
|
|
38
|
+
Write-Host ""
|
|
39
|
+
Write-Host "Done. Press Enter to close..."
|
|
40
|
+
Read-Host | Out-Null
|
|
41
|
+
}
|
package/install/cli.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const { install } = require('./install');
|
|
4
|
+
const { uninstall } = require('./uninstall');
|
|
5
|
+
const { installSkill, uninstallSkill } = require('./skill');
|
|
6
|
+
const paths = require('./paths');
|
|
7
|
+
const manifest = require('./manifest');
|
|
8
|
+
|
|
9
|
+
function status() {
|
|
10
|
+
const data = manifest.read(paths.manifestPath());
|
|
11
|
+
if (!data) {
|
|
12
|
+
console.log('win-nice is not installed.');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
console.log(`win-nice ${data.version} installed at ${data.installedAt}`);
|
|
16
|
+
console.log(`bin dir: ${data.binDir}`);
|
|
17
|
+
console.log(`files: ${Array.isArray(data.files) ? data.files.join(', ') : '(manifest is malformed - run reinstall)'}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function main() {
|
|
21
|
+
const cmd = process.argv[2] || 'install';
|
|
22
|
+
const updatePath = !process.env.WIN_NICE_NO_PATH;
|
|
23
|
+
switch (cmd) {
|
|
24
|
+
case 'install':
|
|
25
|
+
install({ updatePath });
|
|
26
|
+
break;
|
|
27
|
+
case 'uninstall':
|
|
28
|
+
uninstall({ updatePath });
|
|
29
|
+
break;
|
|
30
|
+
case 'reinstall':
|
|
31
|
+
uninstall({ updatePath });
|
|
32
|
+
install({ updatePath });
|
|
33
|
+
break;
|
|
34
|
+
case 'status':
|
|
35
|
+
status();
|
|
36
|
+
break;
|
|
37
|
+
case 'skill': {
|
|
38
|
+
const sub = process.argv[3];
|
|
39
|
+
if (sub === 'install') {
|
|
40
|
+
const results = installSkill();
|
|
41
|
+
if (results.some((r) => !r.installed)) process.exitCode = 1;
|
|
42
|
+
} else if (sub === 'uninstall') {
|
|
43
|
+
const results = uninstallSkill();
|
|
44
|
+
// 'missing' (nothing to remove) isn't a failure; a stripped/foreign
|
|
45
|
+
// marker (skip-due-to-conflict) is - it means removal didn't happen
|
|
46
|
+
// when a real file was there.
|
|
47
|
+
if (results.some((r) => !r.removed && r.reason !== 'missing')) process.exitCode = 1;
|
|
48
|
+
} else {
|
|
49
|
+
console.error(`unknown skill command: ${sub}`);
|
|
50
|
+
console.error('usage: win-nice skill <install|uninstall>');
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
console.error(`unknown command: ${cmd}`);
|
|
57
|
+
console.error('usage: win-nice <install|uninstall|reinstall|status|skill>');
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
main();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const paths = require('./paths');
|
|
5
|
+
const manifest = require('./manifest');
|
|
6
|
+
const { removeManagedFile } = require('./uninstall');
|
|
7
|
+
const pkg = require('../package.json');
|
|
8
|
+
|
|
9
|
+
const SOURCE_BIN = path.join(__dirname, '..', 'bin');
|
|
10
|
+
|
|
11
|
+
function listSourceFiles() {
|
|
12
|
+
// .bat/.ps1 launchers plus their extensionless POSIX shell shims (bin/<tool>,
|
|
13
|
+
// no dot) - the sibling Git Bash needs since it ignores PATHEXT on bare names.
|
|
14
|
+
return fs.readdirSync(SOURCE_BIN).filter((f) => f.endsWith('.bat') || f.endsWith('.ps1') || !f.includes('.'));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Guards against `npm install`/`npm test` inside a source checkout silently
|
|
18
|
+
// touching the real system PATH - only a genuine package install (running from
|
|
19
|
+
// inside someone's node_modules) or an explicit WIN_NICE_HOME override proceeds.
|
|
20
|
+
function isSourceCheckout() {
|
|
21
|
+
return fs.existsSync(path.join(__dirname, '..', '.git'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// `npm install -g win-nice@newer` only runs postinstall (this function) - unlike
|
|
25
|
+
// `win-nice reinstall`, which does uninstall()+install(), it never diffs against
|
|
26
|
+
// what a previous version left behind. Without this, a tool dropped in a newer
|
|
27
|
+
// version stays orphaned in binDir forever. Safe to call before the target dir
|
|
28
|
+
// even exists (read() returns null, staleNames is empty).
|
|
29
|
+
function cleanupStaleFiles(dir, currentFiles) {
|
|
30
|
+
const previous = manifest.read(paths.manifestPath());
|
|
31
|
+
if (!previous || !Array.isArray(previous.files)) return;
|
|
32
|
+
|
|
33
|
+
const currentSet = new Set(currentFiles);
|
|
34
|
+
const staleNames = previous.files.filter((name) => !currentSet.has(name));
|
|
35
|
+
const previousDir = previous.binDir || dir;
|
|
36
|
+
for (const name of staleNames) {
|
|
37
|
+
removeManagedFile(path.join(previousDir, name), dir, { requireMarker: false });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function install({ updatePath = true } = {}) {
|
|
42
|
+
if (!process.env.WIN_NICE_HOME && isSourceCheckout()) {
|
|
43
|
+
console.log(
|
|
44
|
+
'Running from a source checkout - skipping real install. ' +
|
|
45
|
+
'Set WIN_NICE_HOME to force a target directory, or install the published package.'
|
|
46
|
+
);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const dir = paths.binDir();
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
|
|
53
|
+
const files = listSourceFiles();
|
|
54
|
+
cleanupStaleFiles(dir, files);
|
|
55
|
+
for (const name of files) {
|
|
56
|
+
fs.copyFileSync(path.join(SOURCE_BIN, name), path.join(dir, name));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
manifest.write(paths.manifestPath(), {
|
|
60
|
+
version: pkg.version,
|
|
61
|
+
installedAt: new Date().toISOString(),
|
|
62
|
+
binDir: dir,
|
|
63
|
+
files,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (updatePath) {
|
|
67
|
+
const current = paths.readUserPath();
|
|
68
|
+
const next = paths.addToPathString(current, dir);
|
|
69
|
+
if (next !== current) {
|
|
70
|
+
paths.writeUserPath(next);
|
|
71
|
+
console.log(`Added ${dir} to your PATH. Restart your terminal for it to take effect.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(`win-nice ${pkg.version} installed: ${files.join(', ')} -> ${dir}`);
|
|
76
|
+
return { dir, files };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { install, listSourceFiles, isSourceCheckout };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const MARKER = 'win-nice: managed-file';
|
|
6
|
+
|
|
7
|
+
function hasMarker(filePath) {
|
|
8
|
+
try {
|
|
9
|
+
return fs.readFileSync(filePath, 'utf8').includes(MARKER);
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function write(manifestFile, data) {
|
|
16
|
+
fs.mkdirSync(path.dirname(manifestFile), { recursive: true });
|
|
17
|
+
fs.writeFileSync(manifestFile, JSON.stringify(data, null, 2) + '\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function read(manifestFile) {
|
|
21
|
+
if (!fs.existsSync(manifestFile)) return null;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { MARKER, hasMarker, write, read };
|
package/install/paths.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { execFileSync } = require('child_process');
|
|
5
|
+
|
|
6
|
+
// WIN_NICE_HOME overrides the install root - used by tests and by anyone who
|
|
7
|
+
// wants a non-default location. Real installs default to %LOCALAPPDATA%\win-nice.
|
|
8
|
+
function installRoot() {
|
|
9
|
+
if (process.env.WIN_NICE_HOME) return process.env.WIN_NICE_HOME;
|
|
10
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
11
|
+
return path.join(localAppData, 'win-nice');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function binDir() {
|
|
15
|
+
return path.join(installRoot(), 'bin');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function manifestPath() {
|
|
19
|
+
return path.join(installRoot(), 'install-manifest.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalize(p) {
|
|
23
|
+
return path.normalize(p).replace(/\\+$/, '').toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Comparison-only stand-in for [Environment]::ExpandEnvironmentVariables: %VAR%
|
|
27
|
+
// references resolve case-insensitively against the current process env (Windows
|
|
28
|
+
// var names are case-insensitive and so is process.env lookup on win32); unknown
|
|
29
|
+
// or malformed references stay literal text instead of throwing or vanishing.
|
|
30
|
+
// Never used on anything we write back - the raw registry string is preserved
|
|
31
|
+
// exactly; this only lets add/remove recognize a raw %VAR% entry (e.g.
|
|
32
|
+
// %LOCALAPPDATA%\win-nice\bin) as the same location as its expanded form.
|
|
33
|
+
function expandEnvRefs(s) {
|
|
34
|
+
return s.replace(/%([^%]*)%/g, (whole, name) => {
|
|
35
|
+
const value = process.env[name];
|
|
36
|
+
return value === undefined ? whole : value;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Two PATH entries point at the same location if their %VAR% references expand
|
|
41
|
+
// to the same directories, even though the registry stores the raw text.
|
|
42
|
+
function comparisonForm(p) {
|
|
43
|
+
return normalize(expandEnvRefs(p));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function addToPathString(currentPath, dir) {
|
|
47
|
+
const parts = currentPath.split(';').filter(Boolean);
|
|
48
|
+
const target = comparisonForm(dir);
|
|
49
|
+
const already = parts.some((p) => comparisonForm(p) === target);
|
|
50
|
+
if (already) return currentPath;
|
|
51
|
+
return [...parts, dir].join(';');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function removeFromPathString(currentPath, dir) {
|
|
55
|
+
const parts = currentPath.split(';').filter(Boolean);
|
|
56
|
+
const target = comparisonForm(dir);
|
|
57
|
+
return parts.filter((p) => comparisonForm(p) !== target).join(';');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function runPowershell(script, extraEnv) {
|
|
61
|
+
return execFileSync('powershell', ['-NoProfile', '-Command', script], {
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Reads a registry string value without OEM-codepage corruption and without %VAR%
|
|
68
|
+
// expansion. Stdout carries Base64 (pure ASCII, safe under any console code page)
|
|
69
|
+
// instead of the raw value - PowerShell 5.1 writes redirected stdout in the console's
|
|
70
|
+
// OEM code page, not UTF-8, which corrupts any non-ASCII character otherwise.
|
|
71
|
+
// keyPath/valueName travel via env vars (UTF-16 on Windows) so they're never
|
|
72
|
+
// re-encoded either. Exported standalone so tests can hit a scratch key, never Path.
|
|
73
|
+
function readRegistryString(keyPath, valueName) {
|
|
74
|
+
const script = [
|
|
75
|
+
'$v = (Get-Item -LiteralPath $env:WIN_NICE_REG_KEY).GetValue(',
|
|
76
|
+
' $env:WIN_NICE_REG_VALUE, \'\', \'DoNotExpandEnvironmentNames\')',
|
|
77
|
+
'[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$v))',
|
|
78
|
+
].join('\n');
|
|
79
|
+
const out = runPowershell(script, { WIN_NICE_REG_KEY: keyPath, WIN_NICE_REG_VALUE: valueName });
|
|
80
|
+
return Buffer.from(out.trim(), 'base64').toString('utf8');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Writes a registry string value, preserving REG_EXPAND_SZ if that was already the
|
|
84
|
+
// value's kind (so %VAR% entries like WindowsApps survive a round trip instead of
|
|
85
|
+
// being frozen into literals). Creates the key if missing, for scratch-key tests.
|
|
86
|
+
function writeRegistryString(keyPath, valueName, value) {
|
|
87
|
+
const script = [
|
|
88
|
+
'if (-not (Test-Path -LiteralPath $env:WIN_NICE_REG_KEY)) {',
|
|
89
|
+
' New-Item -Path $env:WIN_NICE_REG_KEY -Force | Out-Null',
|
|
90
|
+
'}',
|
|
91
|
+
'$kind = \'String\'',
|
|
92
|
+
'try {',
|
|
93
|
+
' if ((Get-Item -LiteralPath $env:WIN_NICE_REG_KEY).GetValueKind($env:WIN_NICE_REG_VALUE) -eq [Microsoft.Win32.RegistryValueKind]::ExpandString) {',
|
|
94
|
+
' $kind = \'ExpandString\'',
|
|
95
|
+
' }',
|
|
96
|
+
'} catch {}',
|
|
97
|
+
'Set-ItemProperty -LiteralPath $env:WIN_NICE_REG_KEY -Name $env:WIN_NICE_REG_VALUE -Value $env:WIN_NICE_REG_NEW_VALUE -Type $kind',
|
|
98
|
+
].join('\n');
|
|
99
|
+
runPowershell(script, {
|
|
100
|
+
WIN_NICE_REG_KEY: keyPath,
|
|
101
|
+
WIN_NICE_REG_VALUE: valueName,
|
|
102
|
+
WIN_NICE_REG_NEW_VALUE: value,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A raw registry write (unlike [Environment]::SetEnvironmentVariable) doesn't notify
|
|
107
|
+
// running processes. Broadcast WM_SETTINGCHANGE so Explorer/new shells pick it up.
|
|
108
|
+
function broadcastEnvironmentChange() {
|
|
109
|
+
const script = [
|
|
110
|
+
'Add-Type -Namespace WinNice -Name NativeMethods -MemberDefinition \'[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);\'',
|
|
111
|
+
'$result = [UIntPtr]::Zero',
|
|
112
|
+
'[WinNice.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1A, [UIntPtr]::Zero, "Environment", 2, 5000, [ref]$result) | Out-Null',
|
|
113
|
+
].join('\n');
|
|
114
|
+
runPowershell(script);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const USER_ENV_KEY = 'HKCU:\\Environment';
|
|
118
|
+
|
|
119
|
+
// Real registry reads/writes - via the registry directly rather than
|
|
120
|
+
// [Environment]::...('User') (OEM-codepage + expansion pitfalls, see
|
|
121
|
+
// readRegistryString) or `setx` (truncates PATH silently past ~1024 chars).
|
|
122
|
+
function readUserPath() {
|
|
123
|
+
return readRegistryString(USER_ENV_KEY, 'Path');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writeUserPath(newPath) {
|
|
127
|
+
writeRegistryString(USER_ENV_KEY, 'Path', newPath);
|
|
128
|
+
broadcastEnvironmentChange();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
installRoot,
|
|
133
|
+
binDir,
|
|
134
|
+
manifestPath,
|
|
135
|
+
addToPathString,
|
|
136
|
+
removeFromPathString,
|
|
137
|
+
readUserPath,
|
|
138
|
+
writeUserPath,
|
|
139
|
+
readRegistryString,
|
|
140
|
+
writeRegistryString,
|
|
141
|
+
};
|
package/install/skill.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
|
|
6
|
+
const MARKER = '<!-- win-nice: managed-skill -->';
|
|
7
|
+
const SOURCE = path.join(__dirname, '..', 'skills', 'win-nice', 'SKILL.md');
|
|
8
|
+
|
|
9
|
+
// WIN_NICE_SKILL_HOME overrides where these live - used by tests, mirroring
|
|
10
|
+
// WIN_NICE_HOME for the bin/ installer.
|
|
11
|
+
function homeDir() {
|
|
12
|
+
return process.env.WIN_NICE_SKILL_HOME || os.homedir();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function targets() {
|
|
16
|
+
const home = homeDir();
|
|
17
|
+
return [
|
|
18
|
+
path.join(home, '.claude', 'skills', 'win-nice', 'SKILL.md'),
|
|
19
|
+
// Not ~/.codex/skills - Codex CLI's current personal-skill location is
|
|
20
|
+
// $HOME/.agents/skills (the open agentskills.io standard's user scope;
|
|
21
|
+
// .codex/skills was an earlier/incorrect assumption, since corrected).
|
|
22
|
+
path.join(home, '.agents', 'skills', 'win-nice', 'SKILL.md'),
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ~/.claude/skills and ~/.agents/skills are shared namespaces, not a directory
|
|
27
|
+
// win-nice owns exclusively (unlike %LOCALAPPDATA%\win-nice\bin for the bin/
|
|
28
|
+
// installer) - a "win-nice" folder there could belong to someone/something else
|
|
29
|
+
// entirely, so installing must never blindly overwrite an existing file.
|
|
30
|
+
function installSkill() {
|
|
31
|
+
const content = fs.readFileSync(SOURCE, 'utf8');
|
|
32
|
+
const results = [];
|
|
33
|
+
for (const target of targets()) {
|
|
34
|
+
if (fs.existsSync(target) && !fs.readFileSync(target, 'utf8').includes(MARKER)) {
|
|
35
|
+
results.push({ file: target, installed: false, reason: 'already exists (not ours - refusing to overwrite)' });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
39
|
+
fs.writeFileSync(target, content);
|
|
40
|
+
results.push({ file: target, installed: true });
|
|
41
|
+
}
|
|
42
|
+
for (const r of results) {
|
|
43
|
+
console.log(r.installed ? `installed skill: ${r.file}` : `skipped ${r.file} (${r.reason})`);
|
|
44
|
+
}
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function uninstallSkill() {
|
|
49
|
+
const results = [];
|
|
50
|
+
for (const target of targets()) {
|
|
51
|
+
if (!fs.existsSync(target)) {
|
|
52
|
+
results.push({ file: target, removed: false, reason: 'missing' });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!fs.readFileSync(target, 'utf8').includes(MARKER)) {
|
|
56
|
+
results.push({ file: target, removed: false, reason: 'marker missing (modified by user?)' });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
fs.unlinkSync(target);
|
|
60
|
+
results.push({ file: target, removed: true });
|
|
61
|
+
}
|
|
62
|
+
for (const r of results) {
|
|
63
|
+
console.log(r.removed ? `removed ${r.file}` : `skipped ${r.file} (${r.reason})`);
|
|
64
|
+
}
|
|
65
|
+
return results;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { installSkill, uninstallSkill, targets, MARKER };
|