arashi 1.34.0 → 1.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -16
- package/bin/arashi.js +181 -1
- package/package.json +3 -1
- package/schema/config.schema.json +34 -0
- package/scripts/uninstall.ps1 +208 -0
- package/scripts/uninstall.sh +303 -0
package/README.md
CHANGED
|
@@ -63,21 +63,22 @@ For a single repository without persisted Arashi configuration, use `aw init --z
|
|
|
63
63
|
|
|
64
64
|
## Core commands
|
|
65
65
|
|
|
66
|
-
| Command | Purpose
|
|
67
|
-
| --------------------------------- |
|
|
68
|
-
| `aw init` | Initialize a workspace
|
|
69
|
-
| `aw add` | Add a repository
|
|
70
|
-
| `aw
|
|
71
|
-
| `aw
|
|
72
|
-
| `aw
|
|
73
|
-
| `aw
|
|
74
|
-
| `aw
|
|
75
|
-
| `aw
|
|
76
|
-
| `aw
|
|
77
|
-
| `aw
|
|
78
|
-
| `aw
|
|
79
|
-
| `aw
|
|
80
|
-
| `aw
|
|
66
|
+
| Command | Purpose |
|
|
67
|
+
| --------------------------------- | ----------------------------------------- |
|
|
68
|
+
| `aw init` | Initialize a workspace |
|
|
69
|
+
| `aw add` | Add a repository |
|
|
70
|
+
| `aw delete` | Delete configured repository dependencies |
|
|
71
|
+
| `aw clone` | Clone configured repositories |
|
|
72
|
+
| `aw configure` | Edit existing workspace settings |
|
|
73
|
+
| `aw create` | Create coordinated worktrees |
|
|
74
|
+
| `aw list` | List worktrees |
|
|
75
|
+
| `aw status` | Show repository status |
|
|
76
|
+
| `aw switch` | Select and open a worktree |
|
|
77
|
+
| `aw pull` / `aw push` / `aw sync` | Synchronize repositories |
|
|
78
|
+
| `aw setup` | Run repository setup steps |
|
|
79
|
+
| `aw remove` / `aw prune` | Clean up branch worktrees and metadata |
|
|
80
|
+
| `aw doctor` | Diagnose workspace problems |
|
|
81
|
+
| `aw update` | Update Arashi |
|
|
81
82
|
|
|
82
83
|
Run `aw --help`, `aw <command> --help`, or use the [complete command reference](https://arashi.haphazard.dev/commands/) for options and examples.
|
|
83
84
|
|
|
@@ -91,13 +92,37 @@ aw shell install
|
|
|
91
92
|
|
|
92
93
|
You can then use `aw switch --cd <filter>` to change the current shell's directory. See the [shell command guide](https://arashi.haphazard.dev/commands/shell/) for manual setup.
|
|
93
94
|
|
|
95
|
+
Remove only the exact managed shell block with `aw shell uninstall --dry-run`, then
|
|
96
|
+
`aw shell uninstall --yes`. This leaves executables, PATH, manifests, and project data untouched.
|
|
97
|
+
|
|
98
|
+
## Uninstallation
|
|
99
|
+
|
|
100
|
+
Inspect the conservative removal plan first, then consent explicitly:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
aw uninstall --dry-run
|
|
104
|
+
aw uninstall --yes
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Package installations delegate to exactly one proven owner using `npm uninstall -g arashi`,
|
|
108
|
+
`pnpm remove -g arashi`, `yarn global remove arashi`, `bun remove -g arashi`, or
|
|
109
|
+
`vp uninstall -g arashi`. Current official direct installations are removed only when their
|
|
110
|
+
schema-v2 manifest proves the exact payload and installer-created PATH state. Legacy,
|
|
111
|
+
manual, modified, or ambiguous installations refuse automatic removal; refresh the same install
|
|
112
|
+
with the current official installer and retry.
|
|
113
|
+
|
|
114
|
+
If the CLI cannot run, use the installed `uninstall.sh` or `uninstall.ps1` helper with the exact
|
|
115
|
+
install directory and dry-run first. Removal preserves workspaces, repositories, worktrees,
|
|
116
|
+
`.arashi.yaml`, Git metadata, configuration, unrelated profile bytes, unrelated install-directory
|
|
117
|
+
files, and the containing install directory.
|
|
118
|
+
|
|
94
119
|
## More documentation
|
|
95
120
|
|
|
96
121
|
- [Getting started](https://arashi.haphazard.dev/getting-started/)
|
|
97
122
|
- [Configuration](https://arashi.haphazard.dev/workflows/config/)
|
|
98
123
|
- [Hooks](https://arashi.haphazard.dev/workflows/hooks/)
|
|
99
124
|
- [Editor and terminal integrations](https://arashi.haphazard.dev/workflows/)
|
|
100
|
-
- [
|
|
125
|
+
- [Agents and automation](https://arashi.haphazard.dev/workflows/agents-and-specs/#automation-and-json)
|
|
101
126
|
- [Local configuration reference](./docs/configuration.md)
|
|
102
127
|
|
|
103
128
|
## Contributing
|
package/bin/arashi.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
3
|
import { existsSync, realpathSync } from "node:fs";
|
|
4
4
|
import { dirname, join, posix, win32 } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
6
7
|
import { formatInstallError, getPlatformInfo, installBinary } from "./install-binary.js";
|
|
7
8
|
import { runNpmManagedUpdate } from "./update.js";
|
|
8
9
|
import { stringifyWrapperJsonEnvelope } from "./update-options.js";
|
|
@@ -88,6 +89,181 @@ export function isExplicitUpdateCommand(argv) {
|
|
|
88
89
|
return argv[0] === "update";
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
export function isExplicitUninstallCommand(argv) {
|
|
93
|
+
return argv[0] === "uninstall";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const removalCommands = {
|
|
97
|
+
npm: ["npm", ["uninstall", "-g", "arashi"]],
|
|
98
|
+
pnpm: ["pnpm", ["remove", "-g", "arashi"]],
|
|
99
|
+
"yarn-classic": ["yarn", ["global", "remove", "arashi"]],
|
|
100
|
+
bun: ["bun", ["remove", "-g", "arashi"]],
|
|
101
|
+
"vite-plus": ["vp", ["uninstall", "-g", "arashi"]],
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function normalizeEvidencePath(value, platform = currentPlatform) {
|
|
105
|
+
const normalized = String(value ?? "")
|
|
106
|
+
.replaceAll("\\", "/")
|
|
107
|
+
.replace(/\/+$/, "");
|
|
108
|
+
return platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function inferOwnerEvidence(
|
|
112
|
+
rootDir,
|
|
113
|
+
env = process.env,
|
|
114
|
+
realpath = realpathSync,
|
|
115
|
+
npmGlobalRoot,
|
|
116
|
+
platform = currentPlatform,
|
|
117
|
+
) {
|
|
118
|
+
const normalized = normalizeEvidencePath(realpath(rootDir), platform);
|
|
119
|
+
const home = normalizeEvidencePath(env.HOME ?? env.USERPROFILE, platform);
|
|
120
|
+
const pnpmHome = normalizeEvidencePath(env.PNPM_HOME, platform);
|
|
121
|
+
const localAppData = normalizeEvidencePath(env.LOCALAPPDATA, platform);
|
|
122
|
+
const appData = normalizeEvidencePath(env.APPDATA, platform);
|
|
123
|
+
const configuredNpmPrefix = normalizeEvidencePath(env.NPM_CONFIG_PREFIX, platform);
|
|
124
|
+
const detectedNpmRoot = normalizeEvidencePath(npmGlobalRoot, platform);
|
|
125
|
+
const evidence = [];
|
|
126
|
+
const add = (owner) => {
|
|
127
|
+
if (!evidence.includes(owner)) evidence.push(owner);
|
|
128
|
+
};
|
|
129
|
+
if (
|
|
130
|
+
pnpmHome &&
|
|
131
|
+
normalized.startsWith(`${pnpmHome}/global/`) &&
|
|
132
|
+
/\/(?:\.pnpm\/[^/]+\/node_modules|node_modules)\/arashi$/.test(normalized)
|
|
133
|
+
)
|
|
134
|
+
add("pnpm");
|
|
135
|
+
if (home && normalized === `${home}/.bun/install/global/node_modules/arashi`) add("bun");
|
|
136
|
+
if (home && normalized === `${home}/.config/yarn/global/node_modules/arashi`)
|
|
137
|
+
add("yarn-classic");
|
|
138
|
+
if (
|
|
139
|
+
localAppData &&
|
|
140
|
+
normalized === `${localAppData}/yarn/data/global/node_modules/arashi`
|
|
141
|
+
)
|
|
142
|
+
add("yarn-classic");
|
|
143
|
+
if (normalized.includes("/.yarn/")) add("yarn-berry");
|
|
144
|
+
if (home && normalized === `${home}/.vite-plus/packages/arashi/current/package`)
|
|
145
|
+
add("vite-plus");
|
|
146
|
+
if (
|
|
147
|
+
normalized === "/usr/local/lib/node_modules/arashi" ||
|
|
148
|
+
normalized === "/usr/lib/node_modules/arashi" ||
|
|
149
|
+
normalized === "/opt/homebrew/lib/node_modules/arashi" ||
|
|
150
|
+
(home &&
|
|
151
|
+
(normalized === `${home}/.npm-global/lib/node_modules/arashi` ||
|
|
152
|
+
(normalized.startsWith(`${home}/.nvm/versions/node/`) &&
|
|
153
|
+
/\/lib\/node_modules\/arashi$/.test(normalized)))) ||
|
|
154
|
+
(appData && normalized === `${appData}/npm/node_modules/arashi`) ||
|
|
155
|
+
(configuredNpmPrefix &&
|
|
156
|
+
(normalized === `${configuredNpmPrefix}/lib/node_modules/arashi` ||
|
|
157
|
+
normalized === `${configuredNpmPrefix}/node_modules/arashi`)) ||
|
|
158
|
+
(detectedNpmRoot && normalized === `${detectedNpmRoot}/arashi`)
|
|
159
|
+
)
|
|
160
|
+
add("npm");
|
|
161
|
+
return evidence;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function spawnRemoval(command, args, options) {
|
|
165
|
+
const spawnImpl = options.spawnImpl ?? spawn;
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const child = spawnImpl(command, args, { stdio: "inherit" });
|
|
168
|
+
child.on("exit", (code) => resolve(typeof code === "number" ? code : 1));
|
|
169
|
+
child.on("error", (error) => {
|
|
170
|
+
(options.error ?? console.error)(`Failed to run ${command}. ${error.message}.`);
|
|
171
|
+
resolve(1);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function detectNpmGlobalRoot(options = {}) {
|
|
177
|
+
try {
|
|
178
|
+
const output = (options.execFileSyncImpl ?? execFileSync)("npm", ["root", "-g"], {
|
|
179
|
+
encoding: "utf8",
|
|
180
|
+
env: options.env ?? process.env,
|
|
181
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
182
|
+
});
|
|
183
|
+
return String(output).trim() || undefined;
|
|
184
|
+
} catch {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function runPackageUninstall(argv, options) {
|
|
190
|
+
if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) {
|
|
191
|
+
(options.log ?? console.log)(`Usage: arashi uninstall [options]
|
|
192
|
+
|
|
193
|
+
Conservatively remove a proven Arashi installation
|
|
194
|
+
|
|
195
|
+
Options:
|
|
196
|
+
-n, --dry-run Inspect the uninstall plan without changing anything
|
|
197
|
+
-y, --yes Apply the completely preflighted uninstall plan
|
|
198
|
+
-h, --help display help for command`);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
const unsupportedOption = argv.find((arg) => !["-n", "--dry-run", "-y", "--yes"].includes(arg));
|
|
202
|
+
if (unsupportedOption) {
|
|
203
|
+
(options.error ?? console.error)(`Unknown uninstall option: ${unsupportedOption}`);
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
let evidence =
|
|
207
|
+
options.ownerEvidence ??
|
|
208
|
+
inferOwnerEvidence(
|
|
209
|
+
options.rootDir ?? join(__dirname, ".."),
|
|
210
|
+
options.env ?? process.env,
|
|
211
|
+
options.realpathSyncImpl ?? realpathSync,
|
|
212
|
+
options.npmGlobalRoot,
|
|
213
|
+
options.platform ?? currentPlatform,
|
|
214
|
+
);
|
|
215
|
+
if (options.ownerEvidence === undefined && evidence.length === 0 && options.npmGlobalRoot === undefined) {
|
|
216
|
+
const detectedNpmRoot = (options.detectNpmGlobalRoot ?? detectNpmGlobalRoot)({
|
|
217
|
+
env: options.env ?? process.env,
|
|
218
|
+
});
|
|
219
|
+
if (detectedNpmRoot) {
|
|
220
|
+
evidence = inferOwnerEvidence(
|
|
221
|
+
options.rootDir ?? join(__dirname, ".."),
|
|
222
|
+
options.env ?? process.env,
|
|
223
|
+
options.realpathSyncImpl ?? realpathSync,
|
|
224
|
+
detectedNpmRoot,
|
|
225
|
+
options.platform ?? currentPlatform,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (evidence.length > 1) {
|
|
230
|
+
(options.error ?? console.error)(`Package-manager ownership is ambiguous: ${evidence.join(", ")}. No command was run.`);
|
|
231
|
+
return 1;
|
|
232
|
+
}
|
|
233
|
+
if (evidence.length === 0) {
|
|
234
|
+
(options.error ?? console.error)("Package-manager ownership is not proven. Remove the package manually with its known owner.");
|
|
235
|
+
return 1;
|
|
236
|
+
}
|
|
237
|
+
const selected = removalCommands[evidence[0]];
|
|
238
|
+
if (!selected) {
|
|
239
|
+
(options.error ?? console.error)(`Package-manager ownership is unsupported: ${evidence[0]}. No command was run.`);
|
|
240
|
+
return 1;
|
|
241
|
+
}
|
|
242
|
+
const [command, args] = selected;
|
|
243
|
+
(options.log ?? console.log)(`Package-manager uninstall: ${command} ${args.join(" ")}`);
|
|
244
|
+
if (argv.includes("-n") || argv.includes("--dry-run")) return 0;
|
|
245
|
+
if (!argv.includes("-y") && !argv.includes("--yes")) {
|
|
246
|
+
const interactive = options.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
247
|
+
if (!interactive) {
|
|
248
|
+
(options.error ?? console.error)("Non-interactive package-manager uninstall requires --yes.");
|
|
249
|
+
return 1;
|
|
250
|
+
}
|
|
251
|
+
let accepted;
|
|
252
|
+
if (options.confirm) accepted = await options.confirm(false);
|
|
253
|
+
else {
|
|
254
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
255
|
+
try {
|
|
256
|
+
const answer = await prompt.question("Remove this package-managed Arashi installation? [y/N] ");
|
|
257
|
+
accepted = /^(?:y|yes)$/i.test(answer.trim());
|
|
258
|
+
} finally {
|
|
259
|
+
prompt.close();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (!accepted) return 0;
|
|
263
|
+
}
|
|
264
|
+
return spawnRemoval(command, args, options);
|
|
265
|
+
}
|
|
266
|
+
|
|
91
267
|
async function runExplicitInstall(argv, options) {
|
|
92
268
|
const binDir = options.binDir ?? __dirname;
|
|
93
269
|
const rootDir = options.rootDir ?? join(binDir, "..");
|
|
@@ -169,6 +345,10 @@ export async function runEntrypoint(argv = process.argv.slice(2), options = {})
|
|
|
169
345
|
return runNpmManagedUpdate(argv.slice(1), options);
|
|
170
346
|
}
|
|
171
347
|
|
|
348
|
+
if (isExplicitUninstallCommand(argv)) {
|
|
349
|
+
return runPackageUninstall(argv.slice(1), options);
|
|
350
|
+
}
|
|
351
|
+
|
|
172
352
|
await ensureInstalled({ ...options, argv });
|
|
173
353
|
} catch (error) {
|
|
174
354
|
errorLog(formatInstallError(error));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arashi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.35.1",
|
|
4
4
|
"description": "Git worktree manager for meta-repositories - The eye of the storm for your development workflow",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
"bin/aw.bat",
|
|
41
41
|
"bin/aw.ps1",
|
|
42
42
|
"schema/config.schema.json",
|
|
43
|
+
"scripts/uninstall.sh",
|
|
44
|
+
"scripts/uninstall.ps1",
|
|
43
45
|
"README.md",
|
|
44
46
|
"LICENSE"
|
|
45
47
|
],
|
|
@@ -83,6 +83,10 @@
|
|
|
83
83
|
"$ref": "#/definitions/ConfigVersion",
|
|
84
84
|
"description": "Configuration schema version for migrations"
|
|
85
85
|
},
|
|
86
|
+
"worktreeNaming": {
|
|
87
|
+
"$ref": "#/definitions/WorktreeNamingConfig",
|
|
88
|
+
"description": "Optional filesystem naming policy for configured create"
|
|
89
|
+
},
|
|
86
90
|
"worktreesDir": {
|
|
87
91
|
"description": "Base directory where worktrees are created (workspace-relative)",
|
|
88
92
|
"type": "string"
|
|
@@ -285,6 +289,36 @@
|
|
|
285
289
|
"SwitchMode": {
|
|
286
290
|
"enum": ["auto", "cd", "launch", "sesh", "herdr"],
|
|
287
291
|
"type": "string"
|
|
292
|
+
},
|
|
293
|
+
"WorktreeNamingBranchSlashes": {
|
|
294
|
+
"enum": ["preserve", "flatten"],
|
|
295
|
+
"type": "string"
|
|
296
|
+
},
|
|
297
|
+
"WorktreeNamingConfig": {
|
|
298
|
+
"additionalProperties": false,
|
|
299
|
+
"description": "Filesystem naming policy for configured create destinations.",
|
|
300
|
+
"properties": {
|
|
301
|
+
"branchSlashes": {
|
|
302
|
+
"$ref": "#/definitions/WorktreeNamingBranchSlashes",
|
|
303
|
+
"description": "Preserve branch slash hierarchy or flatten slashes to hyphens"
|
|
304
|
+
},
|
|
305
|
+
"maxPathLength": {
|
|
306
|
+
"description": "Maximum UTF-16 code units for each absolute newly planned configured-worktree destination",
|
|
307
|
+
"maximum": 2147483647,
|
|
308
|
+
"minimum": 1,
|
|
309
|
+
"multipleOf": 1,
|
|
310
|
+
"type": "number"
|
|
311
|
+
},
|
|
312
|
+
"style": {
|
|
313
|
+
"$ref": "#/definitions/WorktreeNamingStyle",
|
|
314
|
+
"description": "Repository-shape-aware default, branch-only, or repository-prefixed naming"
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
"type": "object"
|
|
318
|
+
},
|
|
319
|
+
"WorktreeNamingStyle": {
|
|
320
|
+
"enum": ["default", "branch", "repo-branch"],
|
|
321
|
+
"type": "string"
|
|
288
322
|
}
|
|
289
323
|
}
|
|
290
324
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#requires -Version 5.1
|
|
2
|
+
[CmdletBinding()]
|
|
3
|
+
param(
|
|
4
|
+
[string]$InstallDir = $env:ARASHI_INSTALL_DIR,
|
|
5
|
+
[switch]$DryRun,
|
|
6
|
+
[switch]$Yes,
|
|
7
|
+
[string]$ParentPid,
|
|
8
|
+
[switch]$TemporarySelf
|
|
9
|
+
)
|
|
10
|
+
Set-StrictMode -Version Latest
|
|
11
|
+
$ErrorActionPreference = "Stop"
|
|
12
|
+
if ([string]::IsNullOrWhiteSpace($InstallDir)) { $InstallDir = Join-Path $env:USERPROFILE ".arashi\bin" }
|
|
13
|
+
$InstallDir = [System.IO.Path]::GetFullPath($InstallDir)
|
|
14
|
+
$ManifestPath = Join-Path $InstallDir ".arashi-managed-entrypoints.json"
|
|
15
|
+
|
|
16
|
+
function Get-ProfileEncoding([byte[]]$Bytes) {
|
|
17
|
+
if ($Bytes.Length -ge 4 -and $Bytes[0] -eq 0x00 -and $Bytes[1] -eq 0x00 -and $Bytes[2] -eq 0xFE -and $Bytes[3] -eq 0xFF) { return @((New-Object System.Text.UTF32Encoding($true, $false, $true)), 4) }
|
|
18
|
+
if ($Bytes.Length -ge 4 -and $Bytes[0] -eq 0xFF -and $Bytes[1] -eq 0xFE -and $Bytes[2] -eq 0x00 -and $Bytes[3] -eq 0x00) { return @((New-Object System.Text.UTF32Encoding($false, $false, $true)), 4) }
|
|
19
|
+
if ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) { return @((New-Object System.Text.UTF8Encoding($false, $true)), 3) }
|
|
20
|
+
if ($Bytes.Length -ge 2 -and $Bytes[0] -eq 0xFE -and $Bytes[1] -eq 0xFF) { return @((New-Object System.Text.UnicodeEncoding($true, $false, $true)), 2) }
|
|
21
|
+
if ($Bytes.Length -ge 2 -and $Bytes[0] -eq 0xFF -and $Bytes[1] -eq 0xFE) { return @((New-Object System.Text.UnicodeEncoding($false, $false, $true)), 2) }
|
|
22
|
+
return @((New-Object System.Text.UTF8Encoding($false, $true)), 0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function Find-ByteSequence([byte[]]$Bytes, [byte[]]$Needle) {
|
|
26
|
+
$matches = New-Object System.Collections.Generic.List[int]
|
|
27
|
+
for ($offset = 0; $offset -le $Bytes.Length - $Needle.Length; $offset++) {
|
|
28
|
+
$matched = $true
|
|
29
|
+
for ($index = 0; $index -lt $Needle.Length; $index++) {
|
|
30
|
+
if ($Bytes[$offset + $index] -ne $Needle[$index]) { $matched = $false; break }
|
|
31
|
+
}
|
|
32
|
+
if ($matched) { $matches.Add($offset) }
|
|
33
|
+
}
|
|
34
|
+
return @($matches)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Test-BytesAt([byte[]]$Bytes, [int]$Offset, [byte[]]$Needle) {
|
|
38
|
+
if ($Offset -lt 0 -or $Offset + $Needle.Length -gt $Bytes.Length) { return $false }
|
|
39
|
+
for ($index = 0; $index -lt $Needle.Length; $index++) {
|
|
40
|
+
if ($Bytes[$Offset + $index] -ne $Needle[$index]) { return $false }
|
|
41
|
+
}
|
|
42
|
+
return $true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Get-CanonicalMarkerPlan([byte[]]$Bytes) {
|
|
46
|
+
$encodingInfo = Get-ProfileEncoding $Bytes
|
|
47
|
+
$encoding = $encodingInfo[0]
|
|
48
|
+
$contentStart = [int]$encodingInfo[1]
|
|
49
|
+
$beginBytes = $encoding.GetBytes("# >>> arashi shell integration >>>")
|
|
50
|
+
$endBytes = $encoding.GetBytes("# <<< arashi shell integration <<<")
|
|
51
|
+
$lfBytes = $encoding.GetBytes("`n")
|
|
52
|
+
$crlfBytes = $encoding.GetBytes("`r`n")
|
|
53
|
+
$rawBegins = @(Find-ByteSequence $Bytes $beginBytes)
|
|
54
|
+
$rawEnds = @(Find-ByteSequence $Bytes $endBytes)
|
|
55
|
+
$canonicalBegins = @($rawBegins | Where-Object {
|
|
56
|
+
($_ -eq $contentStart -or (Test-BytesAt $Bytes ($_ - $lfBytes.Length) $lfBytes)) -and
|
|
57
|
+
($_ + $beginBytes.Length -eq $Bytes.Length -or (Test-BytesAt $Bytes ($_ + $beginBytes.Length) $lfBytes) -or (Test-BytesAt $Bytes ($_ + $beginBytes.Length) $crlfBytes))
|
|
58
|
+
})
|
|
59
|
+
$canonicalEnds = @($rawEnds | Where-Object {
|
|
60
|
+
($_ -eq $contentStart -or (Test-BytesAt $Bytes ($_ - $lfBytes.Length) $lfBytes)) -and
|
|
61
|
+
($_ + $endBytes.Length -eq $Bytes.Length -or (Test-BytesAt $Bytes ($_ + $endBytes.Length) $lfBytes) -or (Test-BytesAt $Bytes ($_ + $endBytes.Length) $crlfBytes))
|
|
62
|
+
})
|
|
63
|
+
if ($rawBegins.Count -eq 0 -and $rawEnds.Count -eq 0) { return $null }
|
|
64
|
+
if ($rawBegins.Count -ne 1 -or $rawEnds.Count -ne 1 -or $canonicalBegins.Count -ne 1 -or $canonicalEnds.Count -ne 1 -or $canonicalBegins[0] -ge $canonicalEnds[0]) {
|
|
65
|
+
throw "Ambiguous Arashi shell integration markers; only a complete canonical marker line can be removed."
|
|
66
|
+
}
|
|
67
|
+
$blockEnd = $canonicalEnds[0] + $endBytes.Length
|
|
68
|
+
$after = New-Object byte[] ($Bytes.Length - ($blockEnd - $canonicalBegins[0]))
|
|
69
|
+
[System.Array]::Copy($Bytes, 0, $after, 0, $canonicalBegins[0])
|
|
70
|
+
[System.Array]::Copy($Bytes, $blockEnd, $after, $canonicalBegins[0], $Bytes.Length - $blockEnd)
|
|
71
|
+
return [PSCustomObject]@{ Start = $canonicalBegins[0]; End = $blockEnd; After = $after }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
if (-not [string]::IsNullOrWhiteSpace($ParentPid)) {
|
|
76
|
+
$parsed = 0
|
|
77
|
+
if (-not [int]::TryParse($ParentPid, [ref]$parsed) -or $parsed -le 0) { throw "Invalid parent PID." }
|
|
78
|
+
try {
|
|
79
|
+
Wait-Process -Id $parsed -Timeout 120 -ErrorAction Stop
|
|
80
|
+
} catch [Microsoft.PowerShell.Commands.ProcessCommandException] {
|
|
81
|
+
if (Get-Process -Id $parsed -ErrorAction SilentlyContinue) { throw "Timed out waiting for parent process $parsed." }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
$installItem = Get-Item -LiteralPath $InstallDir -Force -ErrorAction Stop
|
|
85
|
+
if (-not $installItem.PSIsContainer -or ($installItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw "Install directory is not a non-reparse directory." }
|
|
86
|
+
$manifestItem = Get-Item -LiteralPath $ManifestPath -Force -ErrorAction Stop
|
|
87
|
+
if ($manifestItem.PSIsContainer -or ($manifestItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw "Ownership manifest is not a regular non-reparse file." }
|
|
88
|
+
$manifestBytes = [System.IO.File]::ReadAllBytes($ManifestPath)
|
|
89
|
+
$manifestEncoding = New-Object System.Text.UTF8Encoding($false, $true)
|
|
90
|
+
$manifestText = $manifestEncoding.GetString($manifestBytes).TrimStart([char]0xFEFF)
|
|
91
|
+
$manifest = $manifestText | ConvertFrom-Json
|
|
92
|
+
$manifestProperties = @($manifest.PSObject.Properties.Name | Sort-Object)
|
|
93
|
+
if (($manifestProperties -join ',') -cnotin @('files,installationChannel,installDirectory,platform,schemaVersion', 'files,installationChannel,installDirectory,pathMutation,platform,schemaVersion')) { throw "Manifest property set is not closed." }
|
|
94
|
+
if ($manifest.schemaVersion -isnot [int] -or $manifest.schemaVersion -ne 2 -or $manifest.installationChannel -isnot [string] -or $manifest.installationChannel -cne "official-direct" -or $manifest.platform -isnot [string] -or $manifest.platform -cne "windows" -or $manifest.installDirectory -isnot [string]) { throw "Unsupported manifest; refresh this direct install first." }
|
|
95
|
+
if ([System.IO.Path]::GetFullPath($manifest.installDirectory) -cne $InstallDir) { throw "Manifest installDirectory mismatch." }
|
|
96
|
+
$expected = @(
|
|
97
|
+
@("arashi.bin.exe", "native-executable"), @("arashi", "canonical-wrapper"),
|
|
98
|
+
@("arashi.ps1", "canonical-powershell-wrapper"), @("arashi.bat", "canonical-cmd-wrapper"),
|
|
99
|
+
@("aw", "alias-wrapper"), @("aw.ps1", "alias-powershell-wrapper"),
|
|
100
|
+
@("aw.bat", "alias-cmd-wrapper"), @("uninstall.ps1", "uninstall-helper")
|
|
101
|
+
)
|
|
102
|
+
if (@($manifest.files).Count -ne $expected.Count) { throw "Manifest payload mismatch." }
|
|
103
|
+
$removable = New-Object System.Collections.Generic.List[string]
|
|
104
|
+
for ($index = 0; $index -lt $expected.Count; $index++) {
|
|
105
|
+
$record = @($manifest.files)[$index]
|
|
106
|
+
$properties = @($record.PSObject.Properties.Name | Sort-Object)
|
|
107
|
+
if (($properties -join ',') -cne 'digest,relativePath,role' -or $record.relativePath -isnot [string] -or $record.role -isnot [string] -or $record.digest -isnot [string] -or $record.relativePath -cne $expected[$index][0] -or $record.role -cne $expected[$index][1] -or $record.digest -cnotmatch '^[a-f0-9]{64}$') { throw "Invalid payload record $index." }
|
|
108
|
+
$path = [System.IO.Path]::GetFullPath((Join-Path $InstallDir $record.relativePath))
|
|
109
|
+
if ((Split-Path -Parent $path) -cne $InstallDir) { throw "Escaping payload path." }
|
|
110
|
+
$item = Get-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
|
|
111
|
+
if ($null -eq $item) { continue }
|
|
112
|
+
if ($item.PSIsContainer -or ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw "$($record.relativePath) is not a regular non-reparse file." }
|
|
113
|
+
if ((Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash.ToLowerInvariant() -cne $record.digest) { throw "$($record.relativePath) digest mismatch." }
|
|
114
|
+
$removable.Add($path)
|
|
115
|
+
}
|
|
116
|
+
$pathEntryToRemove = $null
|
|
117
|
+
if ($manifestProperties -contains 'pathMutation') {
|
|
118
|
+
$pathProperties = @($manifest.pathMutation.PSObject.Properties.Name | Sort-Object)
|
|
119
|
+
if (($pathProperties -join ',') -cne 'created,entry' -or $manifest.pathMutation.created -isnot [bool] -or $manifest.pathMutation.entry -isnot [string] -or [string]::IsNullOrEmpty($manifest.pathMutation.entry)) { throw "Invalid user PATH provenance." }
|
|
120
|
+
if ($manifest.pathMutation.created) {
|
|
121
|
+
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
|
122
|
+
$userPathEntries = if ([string]::IsNullOrEmpty($userPath)) { @() } else { @($userPath -split ';') }
|
|
123
|
+
$matches = @($userPathEntries | Where-Object { $_ -ceq $manifest.pathMutation.entry })
|
|
124
|
+
if ($matches.Count -eq 1) { $pathEntryToRemove = $manifest.pathMutation.entry }
|
|
125
|
+
elseif ($matches.Count -gt 1) { Write-Warning "Created user PATH entry is ambiguous and will be preserved." }
|
|
126
|
+
}
|
|
127
|
+
# created: false is deliberately preserved.
|
|
128
|
+
}
|
|
129
|
+
$shellPlans = @()
|
|
130
|
+
$shellHome = if (-not [string]::IsNullOrWhiteSpace($env:HOME)) { $env:HOME } else { $env:USERPROFILE }
|
|
131
|
+
if (-not [string]::IsNullOrWhiteSpace($shellHome)) {
|
|
132
|
+
$shellCandidates = @(
|
|
133
|
+
(Join-Path $shellHome ".zshrc"),
|
|
134
|
+
(Join-Path $shellHome ".config\fish\config.fish"),
|
|
135
|
+
(Join-Path $shellHome ".bashrc"),
|
|
136
|
+
(Join-Path $shellHome ".bash_profile"),
|
|
137
|
+
(Join-Path $shellHome ".profile")
|
|
138
|
+
) | Select-Object -Unique
|
|
139
|
+
foreach ($shellTarget in $shellCandidates) {
|
|
140
|
+
$shellItem = Get-Item -LiteralPath $shellTarget -Force -ErrorAction SilentlyContinue
|
|
141
|
+
if ($null -eq $shellItem) { continue }
|
|
142
|
+
if ($shellItem.PSIsContainer -or ($shellItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
|
143
|
+
Write-Warning "Preserving unsafe shell startup target: $shellTarget"
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
$shellBytes = [System.IO.File]::ReadAllBytes($shellTarget)
|
|
147
|
+
try { $markerPlan = Get-CanonicalMarkerPlan $shellBytes } catch { throw "$($_.Exception.Message) Target: $shellTarget" }
|
|
148
|
+
if ($null -ne $markerPlan) { $shellPlans += [PSCustomObject]@{ Path = $shellTarget; Before = $shellBytes; After = $markerPlan.After } }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
Write-Host "Installation channel: official-direct"
|
|
152
|
+
foreach ($path in $removable) { Write-Host "- remove: $path" }
|
|
153
|
+
foreach ($shellPlan in $shellPlans) { Write-Host "- remove exact managed shell block: $($shellPlan.Path)" }
|
|
154
|
+
Write-Host "Preserved: projects, Git data, unrelated user state, and install-directory neighbors."
|
|
155
|
+
if ($DryRun) { exit 0 }
|
|
156
|
+
if (-not $Yes) {
|
|
157
|
+
if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) { throw "Non-interactive uninstall requires -Yes." }
|
|
158
|
+
if ((Read-Host "Remove this proven Arashi installation? [y/N]") -notmatch '^(?i:y|yes)$') { Write-Host "Uninstall declined."; exit 0 }
|
|
159
|
+
}
|
|
160
|
+
$currentInstallItem = Get-Item -LiteralPath $InstallDir -Force -ErrorAction Stop
|
|
161
|
+
if (-not $currentInstallItem.PSIsContainer -or ($currentInstallItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw "Install directory changed after preflight." }
|
|
162
|
+
$currentManifestItem = Get-Item -LiteralPath $ManifestPath -Force -ErrorAction Stop
|
|
163
|
+
if ($currentManifestItem.PSIsContainer -or ($currentManifestItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw "Ownership manifest changed after preflight." }
|
|
164
|
+
$currentManifestBytes = [System.IO.File]::ReadAllBytes($ManifestPath)
|
|
165
|
+
if ([Convert]::ToBase64String($currentManifestBytes) -cne [Convert]::ToBase64String($manifestBytes)) { throw "Ownership manifest changed after preflight." }
|
|
166
|
+
for ($index = 0; $index -lt $expected.Count; $index++) {
|
|
167
|
+
$record = @($manifest.files)[$index]
|
|
168
|
+
$path = [System.IO.Path]::GetFullPath((Join-Path $InstallDir $record.relativePath))
|
|
169
|
+
if ($removable -notcontains $path) { continue }
|
|
170
|
+
$item = Get-Item -LiteralPath $path -Force -ErrorAction Stop
|
|
171
|
+
if ($item.PSIsContainer -or ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw "$($record.relativePath) changed after preflight." }
|
|
172
|
+
if ((Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash.ToLowerInvariant() -cne $record.digest) { throw "$($record.relativePath) changed after preflight." }
|
|
173
|
+
}
|
|
174
|
+
foreach ($shellPlan in $shellPlans) {
|
|
175
|
+
$currentShellBytes = [System.IO.File]::ReadAllBytes($shellPlan.Path)
|
|
176
|
+
if ([Convert]::ToBase64String($currentShellBytes) -cne [Convert]::ToBase64String($shellPlan.Before)) { throw "Shell startup file changed after preflight." }
|
|
177
|
+
$shellTemporary = "$($shellPlan.Path).arashi-uninstall-$([System.Guid]::NewGuid().ToString('N')).tmp"
|
|
178
|
+
try {
|
|
179
|
+
[System.IO.File]::WriteAllBytes($shellTemporary, $shellPlan.After)
|
|
180
|
+
Move-Item -LiteralPath $shellTemporary -Destination $shellPlan.Path -Force
|
|
181
|
+
} finally { Remove-Item -LiteralPath $shellTemporary -Force -ErrorAction SilentlyContinue }
|
|
182
|
+
}
|
|
183
|
+
if ($null -ne $pathEntryToRemove) {
|
|
184
|
+
$currentUserPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
|
185
|
+
$entriesBeforeRemoval = if ([string]::IsNullOrEmpty($currentUserPath)) { @() } else { @($currentUserPath -split ';') }
|
|
186
|
+
$currentMatches = @($entriesBeforeRemoval | Where-Object { $_ -ceq $pathEntryToRemove })
|
|
187
|
+
if ($currentMatches.Count -ne 1) { throw "Created user PATH entry changed after preflight." }
|
|
188
|
+
$removed = $false
|
|
189
|
+
$entries = @($entriesBeforeRemoval | Where-Object {
|
|
190
|
+
if (-not $removed -and $_ -ceq $pathEntryToRemove) { $removed = $true; return $false }
|
|
191
|
+
return $true
|
|
192
|
+
})
|
|
193
|
+
[Environment]::SetEnvironmentVariable("Path", ($entries -join ';'), "User")
|
|
194
|
+
}
|
|
195
|
+
foreach ($path in $removable) { Remove-Item -LiteralPath $path -Force }
|
|
196
|
+
Remove-Item -LiteralPath $ManifestPath -Force
|
|
197
|
+
} catch {
|
|
198
|
+
[Console]::Error.WriteLine("error: $($_.Exception.Message)")
|
|
199
|
+
exit 1
|
|
200
|
+
} finally {
|
|
201
|
+
if ($TemporarySelf) {
|
|
202
|
+
$self = $MyInvocation.MyCommand.Path
|
|
203
|
+
$selfDirectory = Split-Path -Parent $self
|
|
204
|
+
if ([System.IO.Path]::GetFileName($selfDirectory) -like "arashi-uninstall-*" -and [System.IO.Path]::GetFileName($self) -ceq "uninstall.ps1") {
|
|
205
|
+
Start-Process -FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -WindowStyle Hidden -ArgumentList @('-NoProfile','-Command',"Start-Sleep -Milliseconds 100; Remove-Item -LiteralPath '$($self.Replace("'", "''"))' -Force; Remove-Item -LiteralPath '$($selfDirectory.Replace("'", "''"))' -Force -ErrorAction SilentlyContinue")
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
INSTALL_DIR="${ARASHI_INSTALL_DIR:-}"
|
|
5
|
+
HOME_DIR="${HOME:-}"
|
|
6
|
+
DRY_RUN=false
|
|
7
|
+
YES=false
|
|
8
|
+
PARENT_PID=""
|
|
9
|
+
TEMPORARY_SELF=false
|
|
10
|
+
|
|
11
|
+
fail() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
|
12
|
+
usage() {
|
|
13
|
+
printf '%s\n' 'Usage: uninstall.sh [--install-dir <path>] [--home-dir <path>] [--dry-run|-n] [--yes|-y] [--parent-pid <pid>]'
|
|
14
|
+
}
|
|
15
|
+
while [ "$#" -gt 0 ]; do
|
|
16
|
+
case "$1" in
|
|
17
|
+
--install-dir) shift; [ "$#" -gt 0 ] || fail "Missing --install-dir value"; INSTALL_DIR="$1" ;;
|
|
18
|
+
--home-dir) shift; [ "$#" -gt 0 ] || fail "Missing --home-dir value"; HOME_DIR="$1" ;;
|
|
19
|
+
--dry-run|-n) DRY_RUN=true ;;
|
|
20
|
+
--yes|-y) YES=true ;;
|
|
21
|
+
--parent-pid) shift; [ "$#" -gt 0 ] || fail "Missing --parent-pid value"; PARENT_PID="$1" ;;
|
|
22
|
+
--temporary-self) TEMPORARY_SELF=true ;;
|
|
23
|
+
--help|-h) usage; exit 0 ;;
|
|
24
|
+
*) fail "Unknown argument: $1" ;;
|
|
25
|
+
esac
|
|
26
|
+
shift
|
|
27
|
+
done
|
|
28
|
+
|
|
29
|
+
if [ -z "$INSTALL_DIR" ]; then
|
|
30
|
+
[ -n "$HOME_DIR" ] || fail "HOME is required when --install-dir is omitted"
|
|
31
|
+
INSTALL_DIR="$HOME_DIR/.arashi/bin"
|
|
32
|
+
fi
|
|
33
|
+
if [ -n "$HOME_DIR" ]; then
|
|
34
|
+
case "$HOME_DIR" in /*) ;; *) fail "--home-dir must be an absolute path" ;; esac
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
cleanup_self() {
|
|
38
|
+
if [ "$TEMPORARY_SELF" = true ]; then
|
|
39
|
+
self_path="${BASH_SOURCE[0]}"
|
|
40
|
+
self_dir="$(dirname "$self_path")"
|
|
41
|
+
case "$(basename "$self_dir"):$(basename "$self_path")" in
|
|
42
|
+
arashi-uninstall-*:uninstall.sh)
|
|
43
|
+
rm -f -- "$self_path"
|
|
44
|
+
rmdir "$self_dir" 2>/dev/null || true
|
|
45
|
+
;;
|
|
46
|
+
esac
|
|
47
|
+
fi
|
|
48
|
+
}
|
|
49
|
+
trap cleanup_self EXIT
|
|
50
|
+
|
|
51
|
+
case "$PARENT_PID" in
|
|
52
|
+
"") ;;
|
|
53
|
+
*[!0-9]*) fail "Invalid parent PID" ;;
|
|
54
|
+
*)
|
|
55
|
+
waited=0
|
|
56
|
+
while kill -0 "$PARENT_PID" 2>/dev/null; do
|
|
57
|
+
[ "$waited" -lt 120 ] || fail "Timed out waiting for parent process $PARENT_PID"
|
|
58
|
+
sleep 1
|
|
59
|
+
waited=$((waited + 1))
|
|
60
|
+
done
|
|
61
|
+
;;
|
|
62
|
+
esac
|
|
63
|
+
|
|
64
|
+
normalize_absolute_path() {
|
|
65
|
+
local input="$1" part output="" index
|
|
66
|
+
local parts=() normalized=()
|
|
67
|
+
case "$input" in /*) ;; *) input="$(pwd)/$input" ;; esac
|
|
68
|
+
IFS='/' read -r -a parts <<< "$input"
|
|
69
|
+
for part in "${parts[@]}"; do
|
|
70
|
+
case "$part" in
|
|
71
|
+
''|.) ;;
|
|
72
|
+
..) if [ "${#normalized[@]}" -gt 0 ]; then index=$((${#normalized[@]} - 1)); unset "normalized[$index]"; fi ;;
|
|
73
|
+
*) normalized+=("$part") ;;
|
|
74
|
+
esac
|
|
75
|
+
done
|
|
76
|
+
for index in "${!normalized[@]}"; do output="$output/${normalized[$index]}"; done
|
|
77
|
+
printf '%s\n' "${output:-/}"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
INSTALL_DIR="$(normalize_absolute_path "$INSTALL_DIR")"
|
|
81
|
+
[ ! -L "$INSTALL_DIR" ] && [ -d "$INSTALL_DIR" ] || fail "install directory is not a regular non-link directory"
|
|
82
|
+
MANIFEST_PATH="$INSTALL_DIR/.arashi-managed-entrypoints.json"
|
|
83
|
+
[ ! -L "$MANIFEST_PATH" ] && [ -f "$MANIFEST_PATH" ] && [ -r "$MANIFEST_PATH" ] || fail "ownership manifest is not a regular non-link file"
|
|
84
|
+
|
|
85
|
+
sha256_file() {
|
|
86
|
+
if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d ' ' -f1
|
|
87
|
+
elif command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d ' ' -f1
|
|
88
|
+
elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}'
|
|
89
|
+
else fail "No SHA-256 tool found (tried shasum, sha256sum, and openssl)"
|
|
90
|
+
fi
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
decode_hex() {
|
|
94
|
+
LC_ALL=C awk -v value="$1" 'function d(c){return index("0123456789abcdef",c)-1} BEGIN{for(i=1;i<=length(value);i+=2)printf "%c",d(substr(value,i,1))*16+d(substr(value,i+1,1))}'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
parse_manifest() {
|
|
98
|
+
LC_ALL=C awk '
|
|
99
|
+
function bad(message) { print "error: " message > "/dev/stderr"; exit 2 }
|
|
100
|
+
function ws() { while (p <= n && substr(s,p,1) ~ /[ \t\r\n]/) p++ }
|
|
101
|
+
function take(c) { ws(); if (substr(s,p,1) != c) bad("malformed ownership manifest"); p++ }
|
|
102
|
+
function string( out,c,e) {
|
|
103
|
+
ws(); if (substr(s,p,1) != "\"") bad("expected manifest string"); p++; out=""
|
|
104
|
+
while (p <= n) {
|
|
105
|
+
c=substr(s,p++,1)
|
|
106
|
+
if (c == "\"") return out
|
|
107
|
+
if (c == "\\") {
|
|
108
|
+
if (p > n) bad("malformed string escape"); e=substr(s,p++,1)
|
|
109
|
+
if (e == "\"" || e == "\\" || e == "/") c=e
|
|
110
|
+
else if (e == "b") c=sprintf("%c",8)
|
|
111
|
+
else if (e == "f") c=sprintf("%c",12)
|
|
112
|
+
else if (e == "n") c="\n"
|
|
113
|
+
else if (e == "r") c="\r"
|
|
114
|
+
else if (e == "t") c="\t"
|
|
115
|
+
else bad("unsupported manifest string escape")
|
|
116
|
+
} else if (c ~ /[\001-\037]/) bad("control byte in manifest string")
|
|
117
|
+
out=out c
|
|
118
|
+
}
|
|
119
|
+
bad("unterminated manifest string")
|
|
120
|
+
}
|
|
121
|
+
function hex(value, i,c,out) { out=""; for(i=1;i<=length(value);i++){c=substr(value,i,1); out=out sprintf("%02x",ord[c])} return out }
|
|
122
|
+
function scalar_number( start) { ws(); start=p; while(substr(s,p,1) ~ /[0-9]/)p++; if(start==p)bad("expected manifest number"); return substr(s,start,p-start) }
|
|
123
|
+
function file_record(idx, key,path,role,digest,count) {
|
|
124
|
+
take("{"); count=0; delete file_seen
|
|
125
|
+
while (1) {
|
|
126
|
+
ws(); if (substr(s,p,1)=="}"){p++;break}
|
|
127
|
+
if(count++)take(","); key=string(); if(file_seen[key]++)bad("duplicate payload property"); take(":")
|
|
128
|
+
if(key=="relativePath")path=string(); else if(key=="role")role=string(); else if(key=="digest")digest=string(); else bad("payload property set is not closed")
|
|
129
|
+
}
|
|
130
|
+
if(count!=3 || path!=expected_path[idx] || role!=expected_role[idx] || digest !~ /^[a-f0-9]{64}$/)bad("invalid payload record")
|
|
131
|
+
print "FILE " idx " " digest
|
|
132
|
+
}
|
|
133
|
+
function files( count) {
|
|
134
|
+
take("["); count=0
|
|
135
|
+
while (1) { ws(); if(substr(s,p,1)=="]"){p++;break}; if(count)take(","); if(count>=4)bad("payload file set mismatch"); file_record(count); count++ }
|
|
136
|
+
if(count!=4)bad("payload file set mismatch")
|
|
137
|
+
}
|
|
138
|
+
function mutation( key,profile,inserted,count) {
|
|
139
|
+
take("{"); count=0; delete mutation_seen
|
|
140
|
+
while(1){ws();if(substr(s,p,1)=="}"){p++;break};if(count++)take(",");key=string();if(mutation_seen[key]++)bad("duplicate pathMutation property");take(":");if(key=="profilePath")profile=string();else if(key=="insertedBytes")inserted=string();else bad("pathMutation property set is not closed")}
|
|
141
|
+
if(count!=2 || profile=="" || substr(profile,1,1)!="/" || inserted=="")bad("invalid POSIX pathMutation")
|
|
142
|
+
print "PROFILE " hex(profile); print "INSERT " hex(inserted)
|
|
143
|
+
}
|
|
144
|
+
BEGIN {
|
|
145
|
+
for(i=0;i<256;i++)ord[sprintf("%c",i)]=i
|
|
146
|
+
expected_path[0]="arashi.bin"; expected_role[0]="native-executable"
|
|
147
|
+
expected_path[1]="arashi"; expected_role[1]="canonical-wrapper"
|
|
148
|
+
expected_path[2]="aw"; expected_role[2]="alias-wrapper"
|
|
149
|
+
expected_path[3]="uninstall.sh"; expected_role[3]="uninstall-helper"
|
|
150
|
+
while((getline line)>0){if(seen_line++)s=s"\n";s=s line} n=length(s);p=1;take("{");count=0
|
|
151
|
+
while(1){ws();if(substr(s,p,1)=="}"){p++;break};if(count++)take(",");key=string();if(top_seen[key]++)bad("duplicate manifest property");take(":")
|
|
152
|
+
if(key=="schemaVersion"){if(scalar_number()!="2")bad("unsupported schema; refresh this direct install first")}
|
|
153
|
+
else if(key=="installationChannel"){if(string()!="official-direct")bad("unsupported installation ownership")}
|
|
154
|
+
else if(key=="platform"){if(string()!="posix")bad("unsupported installation ownership")}
|
|
155
|
+
else if(key=="installDirectory")print "INSTALL " hex(string())
|
|
156
|
+
else if(key=="files")files()
|
|
157
|
+
else if(key=="pathMutation")mutation()
|
|
158
|
+
else bad("manifest property set is not closed")
|
|
159
|
+
}
|
|
160
|
+
ws();if(p<=n)bad("trailing manifest data")
|
|
161
|
+
if(!top_seen["schemaVersion"]||!top_seen["installationChannel"]||!top_seen["platform"]||!top_seen["installDirectory"]||!top_seen["files"]||count<5||count>6)bad("manifest property set is not closed")
|
|
162
|
+
}' "$MANIFEST_PATH"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
PARSED_MANIFEST="$(mktemp "${TMPDIR:-/tmp}/arashi-manifest.XXXXXX")" || fail "Unable to stage manifest validation"
|
|
166
|
+
INSERTED_FILE="$(mktemp "${TMPDIR:-/tmp}/arashi-inserted.XXXXXX")" || fail "Unable to stage PATH provenance"
|
|
167
|
+
cleanup_work_files() { rm -f -- "$PARSED_MANIFEST" "$PARSED_MANIFEST.recheck" "$INSERTED_FILE"; }
|
|
168
|
+
trap 'cleanup_work_files; cleanup_self' EXIT
|
|
169
|
+
parse_manifest > "$PARSED_MANIFEST" || fail "ownership manifest validation failed"
|
|
170
|
+
|
|
171
|
+
MANIFEST_INSTALL=""
|
|
172
|
+
PROFILE_PATH=""
|
|
173
|
+
FILE_DIGESTS=()
|
|
174
|
+
while IFS=' ' read -r kind first second; do
|
|
175
|
+
case "$kind" in
|
|
176
|
+
INSTALL) MANIFEST_INSTALL="$(decode_hex "$first")" ;;
|
|
177
|
+
FILE) FILE_DIGESTS[$first]="$second" ;;
|
|
178
|
+
PROFILE) PROFILE_PATH="$(decode_hex "$first")" ;;
|
|
179
|
+
INSERT) decode_hex "$first" > "$INSERTED_FILE" ;;
|
|
180
|
+
*) fail "invalid parsed manifest record" ;;
|
|
181
|
+
esac
|
|
182
|
+
done < "$PARSED_MANIFEST"
|
|
183
|
+
[ "$MANIFEST_INSTALL" = "$INSTALL_DIR" ] || fail "installDirectory mismatch"
|
|
184
|
+
|
|
185
|
+
file_occurrences() {
|
|
186
|
+
local haystack="$1" needle="$2" haystack_size needle_size offset count=0 first=-1 candidate first_byte
|
|
187
|
+
haystack_size="$(wc -c < "$haystack" | tr -d '[:space:]')"
|
|
188
|
+
needle_size="$(wc -c < "$needle" | tr -d '[:space:]')"
|
|
189
|
+
[ "$needle_size" -gt 0 ] || { printf '0 -1\n'; return; }
|
|
190
|
+
candidate="$(mktemp "${TMPDIR:-/tmp}/arashi-match.XXXXXX")" || fail "Unable to stage byte comparison"
|
|
191
|
+
first_byte="$(od -An -N1 -t u1 "$needle" | tr -d '[:space:]')"
|
|
192
|
+
while read -r offset; do
|
|
193
|
+
[ "$offset" -le $((haystack_size - needle_size)) ] || continue
|
|
194
|
+
dd if="$haystack" of="$candidate" bs=1 skip="$offset" count="$needle_size" 2>/dev/null
|
|
195
|
+
if cmp -s "$candidate" "$needle"; then count=$((count + 1)); [ "$first" -ge 0 ] || first="$offset"; fi
|
|
196
|
+
done < <(od -An -v -t u1 "$haystack" | awk -v wanted="$first_byte" '{for(i=1;i<=NF;i++){if($i==wanted)print offset;offset++}}')
|
|
197
|
+
rm -f -- "$candidate"
|
|
198
|
+
printf '%s %s\n' "$count" "$first"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
marker_state() {
|
|
202
|
+
LC_ALL=C awk -v begin='# >>> arashi shell integration >>>' -v end='# <<< arashi shell integration <<<' '
|
|
203
|
+
function occurrences(line,needle, at,count,rest){rest=line;while((at=index(rest,needle))>0){count++;rest=substr(rest,at+length(needle))}return count}
|
|
204
|
+
{ raw_begin+=occurrences($0,begin);raw_end+=occurrences($0,end);if($0==begin || $0==begin "\r"){exact_begin++;begin_offset=offset}if($0==end || $0==end "\r"){exact_end++;end_offset=offset+length(end)}offset+=length($0)+1 }
|
|
205
|
+
END{print raw_begin+0,raw_end+0,exact_begin+0,exact_end+0,begin_offset+0,end_offset+0}' "$1"
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
copy_without_range() {
|
|
209
|
+
local path="$1" start="$2" end="$3" size temporary
|
|
210
|
+
size="$(wc -c < "$path" | tr -d '[:space:]')"
|
|
211
|
+
temporary="$(mktemp "$(dirname "$path")/.arashi-uninstall.XXXXXX")" || fail "Unable to stage profile rewrite"
|
|
212
|
+
cp -p "$path" "$temporary" || { rm -f -- "$temporary"; fail "Unable to preserve profile metadata"; }
|
|
213
|
+
: > "$temporary"
|
|
214
|
+
if [ "$start" -gt 0 ]; then dd if="$path" of="$temporary" bs=1 count="$start" 2>/dev/null; fi
|
|
215
|
+
if [ "$end" -lt "$size" ]; then dd if="$path" of="$temporary" bs=1 skip="$end" seek="$start" 2>/dev/null; fi
|
|
216
|
+
mv -f -- "$temporary" "$path"
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
EXPECTED_NAMES=("arashi.bin" "arashi" "aw" "uninstall.sh")
|
|
220
|
+
FILE_ACTIONS=()
|
|
221
|
+
SHELL_PATHS=()
|
|
222
|
+
SHELL_STARTS=()
|
|
223
|
+
SHELL_ENDS=()
|
|
224
|
+
SHELL_DIGESTS=()
|
|
225
|
+
SHELL_PRESERVED=()
|
|
226
|
+
SHELL_PRESERVED_COUNT=0
|
|
227
|
+
PROFILE_ACTION=""
|
|
228
|
+
PROFILE_OFFSET=-1
|
|
229
|
+
|
|
230
|
+
preflight() {
|
|
231
|
+
local blockers="" index name path actual state raw_begin raw_end exact_begin exact_end begin_offset end_offset count offset normalized_profile
|
|
232
|
+
FILE_ACTIONS=(); SHELL_PATHS=(); SHELL_STARTS=(); SHELL_ENDS=(); SHELL_DIGESTS=(); SHELL_PRESERVED=(); SHELL_PRESERVED_COUNT=0; PROFILE_ACTION=""; PROFILE_OFFSET=-1
|
|
233
|
+
[ ! -L "$MANIFEST_PATH" ] && [ -f "$MANIFEST_PATH" ] || fail "ownership manifest changed after preflight"
|
|
234
|
+
parse_manifest > "$PARSED_MANIFEST.recheck" || fail "ownership manifest changed after preflight"
|
|
235
|
+
cmp -s "$PARSED_MANIFEST" "$PARSED_MANIFEST.recheck" || fail "ownership manifest changed after preflight"
|
|
236
|
+
rm -f -- "$PARSED_MANIFEST.recheck"
|
|
237
|
+
for index in 0 1 2 3; do
|
|
238
|
+
name="${EXPECTED_NAMES[$index]}"; path="$INSTALL_DIR/$name"
|
|
239
|
+
if [ ! -e "$path" ] && [ ! -L "$path" ]; then FILE_ACTIONS[$index]="absent"
|
|
240
|
+
elif [ -L "$path" ]; then blockers="${blockers}\n- $name is a symbolic link"
|
|
241
|
+
elif [ ! -f "$path" ]; then blockers="${blockers}\n- $name is not a regular file"
|
|
242
|
+
else actual="$(sha256_file "$path")"; if [ "$actual" != "${FILE_DIGESTS[$index]}" ]; then blockers="${blockers}\n- $name digest mismatch (modified)"; else FILE_ACTIONS[$index]="remove"; fi
|
|
243
|
+
fi
|
|
244
|
+
done
|
|
245
|
+
if [ -n "$PROFILE_PATH" ]; then
|
|
246
|
+
case "$PROFILE_PATH" in /*) ;; *) fail "invalid POSIX pathMutation" ;; esac
|
|
247
|
+
if [ ! -e "$PROFILE_PATH" ] && [ ! -L "$PROFILE_PATH" ]; then PROFILE_ACTION="absent"
|
|
248
|
+
elif [ -L "$PROFILE_PATH" ] || [ ! -f "$PROFILE_PATH" ] || [ ! -r "$PROFILE_PATH" ]; then PROFILE_ACTION="preserved"
|
|
249
|
+
else
|
|
250
|
+
normalized_profile="$(normalize_absolute_path "$PROFILE_PATH")"
|
|
251
|
+
[ "$normalized_profile" = "$PROFILE_PATH" ] || fail "invalid POSIX pathMutation"
|
|
252
|
+
read -r count offset < <(file_occurrences "$PROFILE_PATH" "$INSERTED_FILE")
|
|
253
|
+
if [ "$count" -eq 1 ]; then PROFILE_ACTION="remove"; PROFILE_OFFSET="$offset"
|
|
254
|
+
elif [ "$count" -eq 0 ]; then PROFILE_ACTION="absent"
|
|
255
|
+
else PROFILE_ACTION="preserved"
|
|
256
|
+
fi
|
|
257
|
+
fi
|
|
258
|
+
fi
|
|
259
|
+
if [ -n "$HOME_DIR" ]; then
|
|
260
|
+
local candidates=("$HOME_DIR/.zshrc" "$HOME_DIR/.config/fish/config.fish")
|
|
261
|
+
if [ "$(uname -s 2>/dev/null || true)" = "Darwin" ]; then candidates+=("$HOME_DIR/.bash_profile" "$HOME_DIR/.bashrc" "$HOME_DIR/.profile"); else candidates+=("$HOME_DIR/.bashrc" "$HOME_DIR/.bash_profile" "$HOME_DIR/.profile"); fi
|
|
262
|
+
for path in "${candidates[@]}"; do
|
|
263
|
+
[ -e "$path" ] || [ -L "$path" ] || continue
|
|
264
|
+
if [ -L "$path" ] || [ ! -f "$path" ] || [ ! -r "$path" ]; then SHELL_PRESERVED[$SHELL_PRESERVED_COUNT]="$path"; SHELL_PRESERVED_COUNT=$((SHELL_PRESERVED_COUNT + 1)); continue; fi
|
|
265
|
+
read -r raw_begin raw_end exact_begin exact_end begin_offset end_offset < <(marker_state "$path")
|
|
266
|
+
if [ "$raw_begin" -eq 0 ] && [ "$raw_end" -eq 0 ]; then continue; fi
|
|
267
|
+
if [ "$raw_begin" -ne 1 ] || [ "$raw_end" -ne 1 ] || [ "$exact_begin" -ne 1 ] || [ "$exact_end" -ne 1 ] || [ "$begin_offset" -ge "$end_offset" ]; then blockers="${blockers}\n- ambiguous shell integration markers in $path"; continue; fi
|
|
268
|
+
index="${#SHELL_PATHS[@]}"; SHELL_PATHS[$index]="$path"; SHELL_STARTS[$index]="$begin_offset"; SHELL_ENDS[$index]="$end_offset"; SHELL_DIGESTS[$index]="$(sha256_file "$path")"
|
|
269
|
+
done
|
|
270
|
+
fi
|
|
271
|
+
[ -z "$blockers" ] || fail "preflight refused:$(printf '%b' "$blockers")"
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
preflight
|
|
275
|
+
printf 'Installation channel: official-direct\nInstall directory: %s\n' "$INSTALL_DIR"
|
|
276
|
+
for index in 0 1 2 3; do printf -- '- %s: %s\n' "${FILE_ACTIONS[$index]}" "${EXPECTED_NAMES[$index]}"; done
|
|
277
|
+
[ -z "$PROFILE_ACTION" ] || printf -- '- PATH state: %s\n' "$PROFILE_ACTION"
|
|
278
|
+
if [ "${#SHELL_PATHS[@]}" -gt 0 ]; then
|
|
279
|
+
for path in "${SHELL_PATHS[@]}"; do printf -- '- remove exact managed shell block: %s\n' "$path"; done
|
|
280
|
+
fi
|
|
281
|
+
if [ "$SHELL_PRESERVED_COUNT" -gt 0 ]; then
|
|
282
|
+
for path in "${SHELL_PRESERVED[@]}"; do printf -- '- preserved unsafe shell startup target: %s (not a readable regular non-link file)\n' "$path"; done
|
|
283
|
+
fi
|
|
284
|
+
printf '%s\n' 'Preserved: projects, Git data, configuration, unrelated profile bytes, install-directory neighbors, and the install directory.'
|
|
285
|
+
[ "$DRY_RUN" = false ] || exit 0
|
|
286
|
+
if [ "$YES" = false ]; then
|
|
287
|
+
[ -t 0 ] && [ -t 1 ] || fail "Non-interactive uninstall requires --yes"
|
|
288
|
+
read -r -p 'Remove this proven Arashi direct installation? [y/N] ' answer
|
|
289
|
+
case "$answer" in y|Y|yes|YES) ;; *) printf '%s\n' 'Uninstall declined.'; exit 0 ;; esac
|
|
290
|
+
fi
|
|
291
|
+
|
|
292
|
+
preflight
|
|
293
|
+
for index in "${!SHELL_PATHS[@]}"; do
|
|
294
|
+
[ "$(sha256_file "${SHELL_PATHS[$index]}")" = "${SHELL_DIGESTS[$index]}" ] || fail "shell target changed after preflight"
|
|
295
|
+
copy_without_range "${SHELL_PATHS[$index]}" "${SHELL_STARTS[$index]}" "${SHELL_ENDS[$index]}"
|
|
296
|
+
done
|
|
297
|
+
if [ "$PROFILE_ACTION" = "remove" ]; then
|
|
298
|
+
read -r current_count current_offset < <(file_occurrences "$PROFILE_PATH" "$INSERTED_FILE")
|
|
299
|
+
[ "$current_count" -eq 1 ] && [ "$current_offset" -eq "$PROFILE_OFFSET" ] || fail "PATH profile changed after preflight"
|
|
300
|
+
copy_without_range "$PROFILE_PATH" "$PROFILE_OFFSET" "$((PROFILE_OFFSET + $(wc -c < "$INSERTED_FILE" | tr -d '[:space:]')))"
|
|
301
|
+
fi
|
|
302
|
+
for index in 0 1 2 3; do [ "${FILE_ACTIONS[$index]}" != "remove" ] || rm -f -- "$INSTALL_DIR/${EXPECTED_NAMES[$index]}"; done
|
|
303
|
+
rm -f -- "$MANIFEST_PATH"
|