arashi 1.33.0 → 1.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -358
- package/bin/arashi.js +181 -1
- package/package.json +4 -2
- package/schema/config.schema.json +34 -0
- package/scripts/uninstall.ps1 +208 -0
- package/scripts/uninstall.sh +303 -0
package/README.md
CHANGED
|
@@ -4,424 +4,130 @@
|
|
|
4
4
|
[](https://github.com/corwinm/arashi/actions/workflows/ci.yml)
|
|
5
5
|
[](https://github.com/corwinm/arashi/blob/main/LICENSE)
|
|
6
6
|
|
|
7
|
-
Arashi is a Git worktree manager for meta-repositories.
|
|
7
|
+
Arashi is a Git worktree manager for meta-repositories. It coordinates related repositories in one feature workspace while each repository keeps its own history, CI, and pull requests.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Arashi makes a polyrepo work like a monorepo during development, while every repository keeps its own history, CI, and pull requests.
|
|
12
|
-
|
|
13
|
-
[Documentation](https://arashi.haphazard.dev)
|
|
9
|
+
[Documentation](https://arashi.haphazard.dev) · [Command reference](https://arashi.haphazard.dev/commands/) · [Workflow guides](https://arashi.haphazard.dev/workflows/)
|
|
14
10
|
|
|
15
11
|
## Installation
|
|
16
12
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
Prerequisites:
|
|
20
|
-
|
|
21
|
-
- `curl`
|
|
22
|
-
- `bash`
|
|
23
|
-
- A SHA-256 tool (`shasum`, `sha256sum`, or `openssl`)
|
|
13
|
+
macOS and Linux:
|
|
24
14
|
|
|
25
15
|
```bash
|
|
26
16
|
curl -fsSL https://arashi.haphazard.dev/install | bash
|
|
27
17
|
```
|
|
28
18
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
curl -fsSL https://arashi.haphazard.dev/install | ARASHI_VERSION=1.4.0 bash
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Verify install:
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
aw --version
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
By default, the installer places `arashi` in `~/.arashi/bin`, adds that path to your shell config, and in interactive installs offers to enable shell integration for `aw switch --cd`.
|
|
42
|
-
Use `aw` for documented workflows. The `arashi` executable remains supported for existing scripts and workflows, and both names run the same implementation. Product names, configuration, environment variables, packages, and native binaries retain their established Arashi names.
|
|
43
|
-
|
|
44
|
-
The installer transaction verifies that both executable entrypoints report the same non-empty version before declaring success.
|
|
45
|
-
|
|
46
|
-
If curl installation fails, or if the smoke test reports a bad release artifact, use npm installation below or the manual release instructions in [`docs/INSTALLATION.md`](./docs/INSTALLATION.md).
|
|
47
|
-
|
|
48
|
-
### Option 2: Install on Windows with PowerShell
|
|
49
|
-
|
|
50
|
-
PowerShell is the canonical Windows installer:
|
|
19
|
+
Windows PowerShell:
|
|
51
20
|
|
|
52
21
|
```powershell
|
|
53
22
|
powershell -c "irm https://arashi.haphazard.dev/install.ps1 | iex"
|
|
54
23
|
```
|
|
55
24
|
|
|
56
|
-
|
|
25
|
+
Open a new terminal after the Windows installer so it inherits the updated user `PATH`.
|
|
57
26
|
|
|
58
|
-
|
|
27
|
+
With npm:
|
|
59
28
|
|
|
60
29
|
```bash
|
|
61
30
|
npm install -g arashi
|
|
62
31
|
```
|
|
63
32
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
To preinstall the binary explicitly, run:
|
|
67
|
-
|
|
68
|
-
```bash
|
|
69
|
-
aw install
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
To check for package updates or refresh the matching platform binary, run:
|
|
73
|
-
|
|
74
|
-
```bash
|
|
75
|
-
aw update --check
|
|
76
|
-
aw update --dry-run
|
|
77
|
-
aw update --yes
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
`aw update` can update npm-managed installs when it can confidently detect the package manager, including npm, pnpm, Yarn, Bun, and Vite+ (`vp update -g arashi`). For official direct-installer installs, `aw update --yes` reruns the platform installer against the current binary directory: the POSIX curl installer on macOS/Linux and a deferred PowerShell installer on Windows after the current Arashi process exits.
|
|
81
|
-
|
|
82
|
-
Verify install:
|
|
33
|
+
Verify the installation:
|
|
83
34
|
|
|
84
35
|
```bash
|
|
85
36
|
aw --version
|
|
86
37
|
```
|
|
87
38
|
|
|
88
|
-
|
|
39
|
+
`aw` is the preferred command name. `arashi` remains available for existing scripts and workflows. See the [installation guide](./docs/INSTALLATION.md) for version pinning, manual installation, and troubleshooting, or the [`update` command guide](https://arashi.haphazard.dev/commands/update/) for upgrades.
|
|
89
40
|
|
|
90
|
-
|
|
41
|
+
## Quick start
|
|
91
42
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
macOS (Apple Silicon):
|
|
95
|
-
|
|
96
|
-
```bash
|
|
97
|
-
curl -L https://github.com/corwinm/arashi/releases/latest/download/arashi-macos-arm64 -o arashi.bin
|
|
98
|
-
curl -L https://github.com/corwinm/arashi/releases/latest/download/arashi -o arashi
|
|
99
|
-
curl -L https://github.com/corwinm/arashi/releases/latest/download/aw -o aw
|
|
100
|
-
chmod +x arashi.bin arashi aw
|
|
101
|
-
sudo install -m 0755 arashi.bin arashi aw /usr/local/bin/
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
Linux (x64):
|
|
105
|
-
|
|
106
|
-
```bash
|
|
107
|
-
curl -L https://github.com/corwinm/arashi/releases/latest/download/arashi-linux-x64 -o arashi.bin
|
|
108
|
-
curl -L https://github.com/corwinm/arashi/releases/latest/download/arashi -o arashi
|
|
109
|
-
curl -L https://github.com/corwinm/arashi/releases/latest/download/aw -o aw
|
|
110
|
-
chmod +x arashi.bin arashi aw
|
|
111
|
-
sudo install -m 0755 arashi.bin arashi aw /usr/local/bin/
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
Windows (PowerShell and Git Bash):
|
|
115
|
-
|
|
116
|
-
```powershell
|
|
117
|
-
# Download arashi-windows-x64.exe, arashi, arashi.ps1, arashi.bat,
|
|
118
|
-
# aw, aw.ps1, aw.bat,
|
|
119
|
-
# and arashi-checksums.txt from the same release.
|
|
120
|
-
# Verify all seven payload files against arashi-checksums.txt.
|
|
121
|
-
# Rename arashi-windows-x64.exe to arashi.bin.exe and keep the seven files together on PATH.
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
Windows manual installation requires `arashi-windows-x64.exe`, `arashi`, `arashi.ps1`, `arashi.bat`, `aw`, `aw.ps1`, `aw.bat`, and `arashi-checksums.txt` from the same release. Verify all seven payload files, rename the executable to `arashi.bin.exe`, and keep the payload together on PATH. Manual release installations do not create the direct installer's ownership ledger; deliberately move or remove the manual payload before later using the direct installer.
|
|
125
|
-
|
|
126
|
-
You can also build from source for local development. Development is pinned to Node.js 24.18.0, which satisfies pnpm 11's Node.js 22.13 minimum, and pnpm 11.20.0; nvm users can select the repository's development runtime with `nvm use`.
|
|
43
|
+
From the repository that will coordinate your projects:
|
|
127
44
|
|
|
128
45
|
```bash
|
|
129
|
-
|
|
130
|
-
pnpm install
|
|
131
|
-
pnpm run build
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
## Command Surface
|
|
135
|
-
|
|
136
|
-
Arashi currently provides these commands:
|
|
137
|
-
|
|
138
|
-
- `aw init`
|
|
139
|
-
- [`aw init --zero-config`](docs/standalone.md) for a single repository with `.worktrees/<branch>` paths and no persisted Arashi configuration
|
|
140
|
-
- `aw install`
|
|
141
|
-
- `aw update [--check] [--dry-run] [--yes]`
|
|
142
|
-
- `aw add <git-url>`
|
|
143
|
-
- `aw clone [--all] [--base <branch>] [--repo-base <repository=branch>]`
|
|
144
|
-
- `aw create <branch> [--base <branch>] [--repo-base <repository=branch>] [--tab] [--tmux|--sesh|--herdr]`
|
|
145
|
-
- `aw list`
|
|
146
|
-
- `aw status`
|
|
147
|
-
- `aw remove <branch|path>`
|
|
148
|
-
- `aw prune [--dry-run]` - clean stale Git worktree metadata
|
|
149
|
-
- `aw switch [filter] [--repos|--all] [--tab] [--cd|--launch] [--tmux|--sesh|--herdr] [--ignore-configured-launcher]`
|
|
150
|
-
- `aw completion <bash|zsh|fish>`
|
|
151
|
-
- `aw shell init <bash|zsh|fish>`
|
|
152
|
-
- `aw shell install`
|
|
153
|
-
- `aw pull`
|
|
154
|
-
- `aw push [--set-upstream] [--dry-run] [--only <repo>] [--json]`
|
|
155
|
-
- `aw sync`
|
|
156
|
-
- `aw setup [--only <repo>] [--verbose]`
|
|
157
|
-
|
|
158
|
-
## Quick Example
|
|
159
|
-
|
|
160
|
-
```bash
|
|
161
|
-
aw init # repository-local ignore rules (default)
|
|
162
|
-
aw init --ignore-scope tracked # opt in to a shared .gitignore block
|
|
46
|
+
aw init
|
|
163
47
|
aw add git@github.com:your-org/frontend.git
|
|
164
48
|
aw add git@github.com:your-org/backend.git
|
|
165
49
|
aw create feature-auth-refresh
|
|
166
|
-
aw create feature-auth-refresh --base feature/auth
|
|
167
|
-
aw create feature-auth-refresh --repo-base @meta=develop --repo-base frontend=release/2.x
|
|
168
|
-
aw clone --all --base main --repo-base frontend=release/2.x
|
|
169
|
-
aw create feature-auth-refresh --launch
|
|
170
|
-
aw create feature-auth-refresh --tmux
|
|
171
|
-
aw create feature-auth-refresh --herdr
|
|
172
|
-
aw create feature-auth-refresh --tab
|
|
173
|
-
aw create feature-auth-refresh --no-launch
|
|
174
|
-
aw shell install
|
|
175
|
-
aw status
|
|
176
|
-
aw switch feature-auth-refresh # parent repo worktrees
|
|
177
|
-
aw switch --repos feature-auth-refresh # child repo worktrees in current workspace
|
|
178
|
-
aw switch --all feature-auth-refresh # all repos
|
|
179
|
-
aw switch --repos docs # repo-name matching in child repos
|
|
180
|
-
aw switch --cd feature-auth-refresh # parent-shell cd when shell integration is active
|
|
181
|
-
aw switch --launch feature-auth-refresh # force launch while preserving a configured launcher
|
|
182
|
-
aw switch --tmux feature-auth-refresh # force a new plain tmux window
|
|
183
|
-
aw switch --herdr feature-auth-refresh # open or focus a persistent Herdr workspace
|
|
184
|
-
aw switch --tab feature-auth-refresh # request a true tab or managed equivalent
|
|
185
|
-
aw switch --ignore-configured-launcher # bypass configured sesh/Herdr mode once
|
|
186
|
-
aw switch --launch --ignore-configured-launcher # force generic automatic launch
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
### Add from a linked parent worktree
|
|
190
|
-
|
|
191
|
-
When `aw add` runs from an active linked parent worktree, Arashi creates one canonical clone in the parent main checkout and leaves it on the child default branch. It then creates the active child as a linked worktree on the coordinated branch and updates only the active parent configuration. Direct-main and configured-bare adds keep their existing single-placement behavior. See the [add command guide](https://arashi.haphazard.dev/commands/add/) for branch, managed-ignore, JSON, and rollback details.
|
|
192
|
-
|
|
193
|
-
Explicit `--tmux` is a per-invocation launcher override for `create` and `switch`; it is not a persisted configuration mode. It requires an active tmux context whose `TMUX` value is non-empty after trimming, uses the selected worktree path as one argv-safe `tmux new-window -c` argument, and does not fall back to another launcher when the prerequisite or launch fails. On `create`, it implies both launch and switch, while validation failures occur before worktree mutation.
|
|
194
|
-
|
|
195
|
-
`--tab` is a CLI-only launch disposition for `create` and `switch`; it is never persisted. It requests a true terminal tab or documented managed-context equivalent, implies launch (and selection for `create`), overrides automatic parent-shell `cd`, and never degrades to a window or another launcher. For switch, it bypasses configured `sesh` or `herdr` launch defaults; for create, it bypasses configured generic or editor-scoped launch defaults. An explicit launcher selector remains authoritative. Unsupported adapters fail with `TAB_DISPOSITION_UNSUPPORTED`; launch/preflight failures use `LAUNCH_FAILED`. Human-only tab launch is incompatible with `--json`.
|
|
196
|
-
|
|
197
|
-
### Managed Git ignore rules
|
|
198
|
-
|
|
199
|
-
Configured workspaces keep `reposDir` and `worktreesDir` effectively ignored. Arashi asks Git
|
|
200
|
-
first, so an existing tracked `.gitignore`, repository-local `.git/info/exclude`, or configured
|
|
201
|
-
global excludes rule is honored without duplication. Missing safe repository-relative rules use
|
|
202
|
-
the common repository's local exclude file by default, including when a command runs in a linked
|
|
203
|
-
worktree.
|
|
204
|
-
|
|
205
|
-
Use `aw init --ignore-scope tracked` when the team wants Arashi-owned rules committed in the
|
|
206
|
-
workspace `.gitignore`. Use `aw init --ignore-scope none` for a fully manual workflow; Arashi
|
|
207
|
-
will warn about unignored managed paths but will not edit ignore files. Running
|
|
208
|
-
`aw init --ignore-scope local` resets that clone-local preference without recreating an
|
|
209
|
-
existing workspace. The explicit `tracked` or `none` preference is stored only in local Git config
|
|
210
|
-
as `arashi.ignoreScope`; Arashi never creates or modifies global Git ignore configuration.
|
|
211
|
-
|
|
212
|
-
`init`, `pull`, `clone`, `add`, and `create` reconcile the same owned rules before materializing
|
|
213
|
-
configured repositories or worktrees. `doctor` reports missing, unsafe, invalid, or stale managed
|
|
214
|
-
ignore state without changing it.
|
|
215
|
-
|
|
216
|
-
## Workflow Guides
|
|
217
|
-
|
|
218
|
-
Use the docs site workflow guides when you want setup guidance by outcome instead of by individual command.
|
|
219
|
-
|
|
220
|
-
For contributors working on Arashi itself, the project planning workflow in the `arashi-arashi` meta-repo now uses OpenSpec. Older SpecKit-oriented references in legacy planning artifacts are historical context only.
|
|
221
|
-
|
|
222
|
-
- Hooks and configuration defaults: [arashi.haphazard.dev/workflows/hooks-and-config](https://arashi.haphazard.dev/workflows/hooks-and-config/)
|
|
223
|
-
- Integrations for Herdr, VSCode, tmux, and `tmux` plus `sesh`: [arashi.haphazard.dev/workflows](https://arashi.haphazard.dev/workflows/)
|
|
224
|
-
- Agent-assisted and spec-driven change flow: [arashi.haphazard.dev/workflows/agents-and-specs](https://arashi.haphazard.dev/workflows/agents-and-specs/)
|
|
225
|
-
|
|
226
|
-
## Shell Integration
|
|
227
|
-
|
|
228
|
-
Use shell integration for parent-shell directory switching and native tab completion. Bash, Zsh, and Fish are supported through both `arashi` and `aw`.
|
|
229
|
-
|
|
230
|
-
The official curl installer can offer this automatically. If you skip it or use npm, install it for the active shell with:
|
|
231
|
-
|
|
232
|
-
```bash
|
|
233
|
-
aw shell install
|
|
234
50
|
```
|
|
235
51
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
For manual setup, activate the wrapper and completion independently:
|
|
52
|
+
Arashi creates matching worktrees for the configured repositories. From there, use:
|
|
239
53
|
|
|
240
54
|
```bash
|
|
241
|
-
#
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
#
|
|
246
|
-
eval "$(command aw shell init zsh)"
|
|
247
|
-
source <(command aw completion zsh)
|
|
248
|
-
|
|
249
|
-
# Fish
|
|
250
|
-
command aw shell init fish | source
|
|
251
|
-
command aw completion fish | source
|
|
55
|
+
aw status # inspect the coordinated workspace
|
|
56
|
+
aw switch feature-auth-refresh # return to a worktree
|
|
57
|
+
aw pull # update repositories
|
|
58
|
+
aw push --set-upstream # publish branches
|
|
59
|
+
aw remove feature-auth-refresh # remove the coordinated worktrees
|
|
252
60
|
```
|
|
253
61
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
Completion covers commands, aliases, options, finite choices, conflicts, and positional boundaries. In an Arashi workspace it also resolves configured repository and group selector segments, switch/remove worktrees and paths, and move source/target references. Dynamic lookup is local and read-only, silent on unavailable or broken metadata, and limited to a 200 ms whole-query budget; it performs no network requests, hooks, prompts, workspace mutations, or child operations. Static completion remains available outside a workspace.
|
|
257
|
-
|
|
258
|
-
Once installed, you can use `aw switch --cd <filter>` for one-off parent-shell switching or set `.arashi/config.json` `defaults.switch.mode` to `"cd"` or contextual `"auto"`. The canonical modes are `auto` | `cd` | `launch` | `sesh` | `herdr`.
|
|
259
|
-
|
|
260
|
-
If shell integration is inactive, `aw switch --cd` warns and skips launch fallback for that invocation.
|
|
261
|
-
|
|
262
|
-
For automated installs, set `ARASHI_SHELL_INTEGRATION=yes` to enable it without prompting or `ARASHI_SHELL_INTEGRATION=no` to skip it.
|
|
263
|
-
|
|
264
|
-
## Hooks
|
|
265
|
-
|
|
266
|
-
Arashi can run file-backed or configured inline lifecycle hooks during `aw create` and
|
|
267
|
-
`aw remove`. Inline workspace values live only at root `hooks.scripts.<lifecycle>`; repository
|
|
268
|
-
values live only at `repos.<name>.hooks.<lifecycle>`. Use inline config for short reviewable commands
|
|
269
|
-
and native files for substantial scripts. One inline/file source may own a logical location; a
|
|
270
|
-
same-location collision fails preflight instead of running both.
|
|
62
|
+
For a single repository without persisted Arashi configuration, use `aw init --zero-config`. See the [standalone workflow](https://arashi.haphazard.dev/workflows/standalone/).
|
|
271
63
|
|
|
272
|
-
|
|
273
|
-
Configured remove evaluates repository, workspace, user-global targeted, and user-global shared scopes
|
|
274
|
-
once per target repository. `--no-hooks` is create-only; `--no-hook-input` applies to both commands.
|
|
275
|
-
Standalone mode remains file-only and activates only targeted and shared user-global hooks.
|
|
64
|
+
## Core commands
|
|
276
65
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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 |
|
|
280
82
|
|
|
281
|
-
|
|
282
|
-
[`docs/hooks.md`](./docs/hooks.md) for exact lookup, lifecycle, environment, and automation behavior.
|
|
83
|
+
Run `aw --help`, `aw <command> --help`, or use the [complete command reference](https://arashi.haphazard.dev/commands/) for options and examples.
|
|
283
84
|
|
|
284
|
-
##
|
|
85
|
+
## Shell integration
|
|
285
86
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
### Jump to a worktree (`cd`)
|
|
289
|
-
|
|
290
|
-
```bash
|
|
291
|
-
# One-off jump
|
|
292
|
-
cd "$(aw list | fzf)"
|
|
293
|
-
```
|
|
87
|
+
Install shell integration for parent-shell directory switching and completion in Bash, Zsh, or Fish:
|
|
294
88
|
|
|
295
89
|
```bash
|
|
296
|
-
|
|
297
|
-
bind '"\C-g":"cd \$(aw list | fzf)\n"'
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
```zsh
|
|
301
|
-
# Zsh keybind (Ctrl+G)
|
|
302
|
-
bindkey -s '^g' 'cd $(aw list | fzf)\n'
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
### Open or switch tmux sessions with `sesh`
|
|
306
|
-
|
|
307
|
-
```bash
|
|
308
|
-
# One-off session connect
|
|
309
|
-
sesh connect "$(aw list | fzf)"
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
```bash
|
|
313
|
-
# Bash keybind (Ctrl+S)
|
|
314
|
-
bind '"\C-s":"sesh connect \$(aw list | fzf)\n"'
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
```zsh
|
|
318
|
-
# Zsh keybind (Ctrl+S)
|
|
319
|
-
bindkey -s '^s' 'sesh connect $(aw list | fzf)\n'
|
|
90
|
+
aw shell install
|
|
320
91
|
```
|
|
321
92
|
|
|
322
|
-
You can
|
|
323
|
-
|
|
324
|
-
`aw switch` checks managed contexts in this order: tmux → Herdr → cmux → integrated IDE → Kitty. Managed Kitty selection applies when any one of `KITTY_PID`, `KITTY_WINDOW_ID`, or `TERM=xterm-kitty` is present after normalization.
|
|
325
|
-
|
|
326
|
-
Managed Kitty requires Kitty 0.43 or newer plus working `kitten @` remote control. Arashi reuses only its exact worktree marker and focuses that tab before launching a new session-backed tab. Once managed Kitty is selected, missing or unsupported tooling, denied remote control, duplicate markers, and validation failures are reported directly instead of falling back to another launcher.
|
|
327
|
-
|
|
328
|
-
`aw create --launch` and `defaults.create.launch: "auto"` use the same managed Kitty behavior. If that post-create launch fails, Arashi exits nonzero but preserves the successfully created worktree and reports the launch as partial success. Fix the launcher problem and use `aw switch`; do not retry creation for the same branch.
|
|
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.
|
|
329
94
|
|
|
330
|
-
|
|
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.
|
|
331
97
|
|
|
332
|
-
|
|
333
|
-
# Select and remove a worktree quickly
|
|
334
|
-
aw remove -f "$(aw list | fzf)"
|
|
335
|
-
```
|
|
98
|
+
## Uninstallation
|
|
336
99
|
|
|
337
|
-
|
|
100
|
+
Inspect the conservative removal plan first, then consent explicitly:
|
|
338
101
|
|
|
339
102
|
```bash
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
## Configuration Schema
|
|
344
|
-
|
|
345
|
-
### Configured worktree files
|
|
346
|
-
|
|
347
|
-
Configured repositories can copy isolated files or create native symlinks for intentionally shared state when coordinated worktrees are created. See [Worktree file materialization](docs/configuration.md#worktree-file-materialization) for direct `copy`/`symlink` arrays, Git-primary sourcing, lifecycle order, safety rules, dry-run previews, and doctor diagnostics.
|
|
348
|
-
|
|
349
|
-
## Configuration Schema
|
|
350
|
-
|
|
351
|
-
Arashi publishes a JSON Schema for `.arashi/config.json` so editors can validate and autocomplete your config.
|
|
352
|
-
|
|
353
|
-
- Stable URL: `https://unpkg.com/arashi/schema/config.schema.json`
|
|
354
|
-
- Version-pinned URL: `https://unpkg.com/arashi@1.7.0/schema/config.schema.json`
|
|
355
|
-
|
|
356
|
-
Example config header:
|
|
357
|
-
|
|
358
|
-
```json
|
|
359
|
-
{
|
|
360
|
-
"$schema": "https://unpkg.com/arashi/schema/config.schema.json",
|
|
361
|
-
"version": "1.0.0",
|
|
362
|
-
"reposDir": "./repos",
|
|
363
|
-
"defaults": {
|
|
364
|
-
"create": {
|
|
365
|
-
"switch": true,
|
|
366
|
-
"launch": "none"
|
|
367
|
-
},
|
|
368
|
-
"editors": {
|
|
369
|
-
"vscode": {
|
|
370
|
-
"create": {
|
|
371
|
-
"launch": "sesh"
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
},
|
|
375
|
-
"switch": {
|
|
376
|
-
"mode": "sesh"
|
|
377
|
-
}
|
|
378
|
-
},
|
|
379
|
-
"repos": {}
|
|
380
|
-
}
|
|
103
|
+
aw uninstall --dry-run
|
|
104
|
+
aw uninstall --yes
|
|
381
105
|
```
|
|
382
106
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
Use `defaults.create` for terminal `aw create` behavior. Use `defaults.editors.<host>.create` for editor-specific overrides such as VS Code extension create flows. Supported hosts are `vscode`, `cursor`, and `kiro`. Each scope has one canonical `launch` choice: `none` | `auto` | `sesh` | `herdr`. `switch` stays independent, while any enabled launch implies switch handling for the newly created primary worktree.
|
|
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.
|
|
390
113
|
|
|
391
|
-
|
|
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.
|
|
392
118
|
|
|
393
|
-
|
|
119
|
+
## More documentation
|
|
394
120
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
- Skill repository: [`repos/arashi-skills`](../arashi-skills/README.md)
|
|
402
|
-
- Canonical skill manifest: [`repos/arashi-skills/skills/arashi/SKILL.md`](../arashi-skills/skills/arashi/SKILL.md)
|
|
403
|
-
- Workflow catalog: [`repos/arashi-skills/skills/arashi/references/workflows.md`](../arashi-skills/skills/arashi/references/workflows.md)
|
|
404
|
-
- Session shortcuts: [`repos/arashi-skills/skills/arashi/references/session-shortcuts.md`](../arashi-skills/skills/arashi/references/session-shortcuts.md)
|
|
405
|
-
|
|
406
|
-
## Documentation
|
|
407
|
-
|
|
408
|
-
- Installation details: [`docs/INSTALLATION.md`](./docs/INSTALLATION.md)
|
|
409
|
-
- Configuration details: [`docs/configuration.md`](./docs/configuration.md)
|
|
410
|
-
- Clone command details: [`docs/commands/clone.md`](./docs/commands/clone.md)
|
|
411
|
-
- Hook behavior: [`docs/hooks.md`](./docs/hooks.md)
|
|
412
|
-
- Workflow guides: [https://arashi.haphazard.dev/workflows/](https://arashi.haphazard.dev/workflows/)
|
|
413
|
-
- Shell integration details: [`docs/commands/shell.md`](./docs/commands/shell.md)
|
|
414
|
-
- Setup command details: [`docs/commands/setup.md`](./docs/commands/setup.md)
|
|
415
|
-
- Switch command details: [`docs/commands/switch.md`](./docs/commands/switch.md)
|
|
416
|
-
- Remove command details: [`docs/commands/remove.md`](./docs/commands/remove.md)
|
|
417
|
-
- Push command details: [`docs/commands/push.md`](./docs/commands/push.md)
|
|
418
|
-
- FZF integration: [`docs/FZF_COMPATIBILITY.md`](./docs/FZF_COMPATIBILITY.md)
|
|
121
|
+
- [Getting started](https://arashi.haphazard.dev/getting-started/)
|
|
122
|
+
- [Configuration](https://arashi.haphazard.dev/workflows/config/)
|
|
123
|
+
- [Hooks](https://arashi.haphazard.dev/workflows/hooks/)
|
|
124
|
+
- [Editor and terminal integrations](https://arashi.haphazard.dev/workflows/)
|
|
125
|
+
- [JSON automation](https://arashi.haphazard.dev/workflows/json-automation/)
|
|
126
|
+
- [Local configuration reference](./docs/configuration.md)
|
|
419
127
|
|
|
420
128
|
## Contributing
|
|
421
129
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
For specs and planning workflow, see the specs repository: [github.com/corwinm/arashi-arashi](https://github.com/corwinm/arashi-arashi).
|
|
130
|
+
See [`CONTRIBUTING.md`](./CONTRIBUTING.md). Planning and specifications live in [corwinm/arashi-arashi](https://github.com/corwinm/arashi-arashi).
|
|
425
131
|
|
|
426
132
|
## License
|
|
427
133
|
|
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.0",
|
|
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
|
],
|
|
@@ -110,5 +112,5 @@
|
|
|
110
112
|
"engines": {
|
|
111
113
|
"node": ">=18.0.0"
|
|
112
114
|
},
|
|
113
|
-
"packageManager": "pnpm@11.
|
|
115
|
+
"packageManager": "pnpm@11.22.0"
|
|
114
116
|
}
|
|
@@ -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"
|