arashi 1.11.3 → 1.13.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 CHANGED
@@ -47,13 +47,31 @@ If curl installation fails, or if the smoke test reports a bad release artifact,
47
47
  npm install -g arashi
48
48
  ```
49
49
 
50
+ The npm package is script-free: it does not require package-manager lifecycle scripts or `postinstall` approval. It installs the lightweight JavaScript entrypoint and wrapper files first, then downloads the matching platform binary on first use.
51
+
52
+ To preinstall the binary explicitly, run:
53
+
54
+ ```bash
55
+ arashi install
56
+ ```
57
+
58
+ To check for package updates or refresh the matching platform binary, run:
59
+
60
+ ```bash
61
+ arashi update --check
62
+ arashi update --dry-run
63
+ arashi update --yes
64
+ ```
65
+
66
+ `arashi 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 curl installer installs, `arashi update --yes` reruns the installer against the current binary directory.
67
+
50
68
  Verify install:
51
69
 
52
70
  ```bash
53
71
  arashi --version
54
72
  ```
55
73
 
56
- If npm is unavailable or fails, use the curl installer command above or the manual release instructions in [`docs/INSTALLATION.md`](./docs/INSTALLATION.md).
74
+ If npm is unavailable or binary installation fails, use the curl installer command above or the manual release instructions in [`docs/INSTALLATION.md`](./docs/INSTALLATION.md).
57
75
 
58
76
  ### Manual install from GitHub Releases
59
77
 
@@ -94,6 +112,8 @@ bun run build
94
112
  Arashi currently provides these commands:
95
113
 
96
114
  - `arashi init`
115
+ - `arashi install`
116
+ - `arashi update [--check] [--dry-run] [--yes]`
97
117
  - `arashi add <git-url>`
98
118
  - `arashi clone [--all]`
99
119
  - `arashi create <branch>`
package/bin/arashi.js CHANGED
@@ -1,110 +1,158 @@
1
1
  #!/usr/bin/env node
2
- import { spawn, spawnSync } from "node:child_process";
2
+ import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
- import { fileURLToPath } from "node:url";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { formatInstallError, getPlatformInfo, installBinary } from "./install-binary.js";
7
+ import { runNpmManagedUpdate } from "./update.js";
6
8
 
7
9
  const __filename = fileURLToPath(import.meta.url);
8
10
  const __dirname = dirname(__filename);
9
11
 
10
- const argv = process.argv.slice(2);
11
- const isWindows = process.platform === "win32";
12
+ const currentPlatform = process.platform;
13
+ const currentArch = process.arch;
14
+ const isWindows = currentPlatform === "win32";
12
15
 
13
- const resolveBinaryPath = () => {
14
- const defaultBinary = isWindows ? "arashi.bin.exe" : "arashi.bin";
15
- const defaultBinaryPath = join(__dirname, defaultBinary);
16
+ export function getDefaultBinaryName(platform = currentPlatform) {
17
+ return platform === "win32" ? "arashi.bin.exe" : "arashi.bin";
18
+ }
16
19
 
17
- if (existsSync(defaultBinaryPath)) {
18
- return defaultBinaryPath;
19
- }
20
+ export function getWrapperName(platform = currentPlatform) {
21
+ return platform === "win32" ? "arashi.bat" : "arashi";
22
+ }
20
23
 
21
- if (isWindows) {
22
- return join(__dirname, "arashi-windows-x64.exe");
23
- }
24
+ export function resolvePlatformBinaryName({ arch = currentArch, platform = currentPlatform } = {}) {
25
+ return getPlatformInfo({ arch, platform }).binaryName;
26
+ }
24
27
 
25
- if (process.platform === "darwin" && process.arch === "arm64") {
26
- return join(__dirname, "arashi-macos-arm64");
27
- }
28
+ export function resolveBinaryPath(options = {}) {
29
+ const binDir = options.binDir ?? __dirname;
30
+ const platform = options.platform ?? currentPlatform;
31
+ const arch = options.arch ?? currentArch;
32
+ const exists = options.existsSyncImpl ?? existsSync;
33
+ const defaultBinaryPath = join(binDir, getDefaultBinaryName(platform));
28
34
 
29
- if (process.platform === "linux" && process.arch === "x64") {
30
- return join(__dirname, "arashi-linux-x64");
35
+ if (exists(defaultBinaryPath)) {
36
+ return defaultBinaryPath;
31
37
  }
32
38
 
33
- return defaultBinaryPath;
34
- };
35
-
36
- const ensureInstalled = () => {
37
- const wrapper = isWindows ? "arashi.bat" : "arashi";
38
- const wrapperPath = join(__dirname, wrapper);
39
- const postinstallPath = join(__dirname, "..", "scripts", "postinstall.js");
40
- const defaultBinary = isWindows ? "arashi.bin.exe" : "arashi.bin";
41
- const defaultBinaryPath = join(__dirname, defaultBinary);
42
- const platformBinary = (() => {
43
- if (isWindows) {
44
- return "arashi-windows-x64.exe";
45
- }
46
-
47
- if (process.platform === "darwin" && process.arch === "arm64") {
48
- return "arashi-macos-arm64";
49
- }
50
-
51
- if (process.platform === "linux" && process.arch === "x64") {
52
- return "arashi-linux-x64";
53
- }
39
+ try {
40
+ return join(binDir, resolvePlatformBinaryName({ arch, platform }));
41
+ } catch {
42
+ return defaultBinaryPath;
43
+ }
44
+ }
54
45
 
55
- return null;
56
- })();
57
- const platformBinaryPath = platformBinary ? join(__dirname, platformBinary) : null;
58
- const binaryExists = () => {
59
- if (existsSync(defaultBinaryPath)) {
60
- return true;
61
- }
46
+ export function hasRunnableBinary(options = {}) {
47
+ const binDir = options.binDir ?? __dirname;
48
+ const platform = options.platform ?? currentPlatform;
49
+ const arch = options.arch ?? currentArch;
50
+ const exists = options.existsSyncImpl ?? existsSync;
62
51
 
63
- if (platformBinaryPath && existsSync(platformBinaryPath)) {
64
- return true;
65
- }
52
+ if (exists(join(binDir, getDefaultBinaryName(platform)))) {
53
+ return true;
54
+ }
66
55
 
56
+ try {
57
+ return exists(join(binDir, resolvePlatformBinaryName({ arch, platform })));
58
+ } catch {
67
59
  return false;
68
- };
69
-
70
- if (existsSync(wrapperPath) && binaryExists()) {
71
- return;
60
+ }
61
+ }
62
+
63
+ export async function ensureInstalled(options = {}) {
64
+ const binDir = options.binDir ?? __dirname;
65
+ const rootDir = options.rootDir ?? join(binDir, "..");
66
+ const platform = options.platform ?? currentPlatform;
67
+ const arch = options.arch ?? currentArch;
68
+ const log = options.log ?? console.log;
69
+ const installBinaryImpl = options.installBinaryImpl ?? installBinary;
70
+
71
+ if (hasRunnableBinary({ arch, binDir, existsSyncImpl: options.existsSyncImpl, platform })) {
72
+ return { status: "already-installed" };
72
73
  }
73
74
 
74
- console.log("arashi binary missing; running postinstall to download.");
75
- const result = spawnSync(process.execPath, [postinstallPath], { stdio: "inherit" });
75
+ log("arashi binary missing; installing the matching platform binary before continuing.");
76
+ return installBinaryImpl({ ...options, arch, binDir, log, platform, rootDir });
77
+ }
78
+
79
+ export function isExplicitInstallCommand(argv) {
80
+ return argv[0] === "install";
81
+ }
82
+
83
+ export function isExplicitUpdateCommand(argv) {
84
+ return argv[0] === "update";
85
+ }
86
+
87
+ async function runExplicitInstall(options) {
88
+ const binDir = options.binDir ?? __dirname;
89
+ const rootDir = options.rootDir ?? join(binDir, "..");
90
+ const installBinaryImpl = options.installBinaryImpl ?? installBinary;
91
+
92
+ await installBinaryImpl({ ...options, binDir, rootDir });
93
+ }
94
+
95
+ function spawnArashi(argv, options = {}) {
96
+ const binDir = options.binDir ?? __dirname;
97
+ const platform = options.platform ?? currentPlatform;
98
+ const windows = platform === "win32";
99
+ const spawnImpl = options.spawnImpl ?? spawn;
100
+ const wrapperPath = join(binDir, getWrapperName(platform));
101
+ const binaryPath = resolveBinaryPath({
102
+ arch: options.arch,
103
+ binDir,
104
+ existsSyncImpl: options.existsSyncImpl,
105
+ platform,
106
+ });
107
+
108
+ return new Promise((resolve) => {
109
+ const child = windows
110
+ ? spawnImpl(binaryPath, argv, {
111
+ stdio: "inherit",
112
+ windowsHide: false,
113
+ })
114
+ : spawnImpl(wrapperPath, argv, { stdio: "inherit" });
115
+
116
+ child.on("exit", (code, signal) => {
117
+ if (typeof code === "number") {
118
+ resolve(code);
119
+ return;
120
+ }
121
+
122
+ resolve(signal ? 1 : 0);
123
+ });
124
+
125
+ child.on("error", (error) => {
126
+ const errorLog = options.error ?? console.error;
127
+ errorLog(`Failed to start arashi. ${error.message}.`);
128
+ resolve(1);
129
+ });
130
+ });
131
+ }
132
+
133
+ export async function runEntrypoint(argv = process.argv.slice(2), options = {}) {
134
+ const errorLog = options.error ?? console.error;
135
+
136
+ try {
137
+ if (isExplicitInstallCommand(argv)) {
138
+ await runExplicitInstall(options);
139
+ return 0;
140
+ }
76
141
 
77
- if (result.error) {
78
- console.error(`Failed to run postinstall. ${result.error.message}.`);
79
- process.exit(1);
80
- }
142
+ if (isExplicitUpdateCommand(argv)) {
143
+ return runNpmManagedUpdate(argv.slice(1), options);
144
+ }
81
145
 
82
- if (typeof result.status === "number" && result.status !== 0) {
83
- process.exit(result.status);
84
- }
85
- };
86
-
87
- ensureInstalled();
88
- const wrapper = isWindows ? "arashi.bat" : "arashi";
89
- const wrapperPath = join(__dirname, wrapper);
90
- const binaryPath = resolveBinaryPath();
91
-
92
- const child = isWindows
93
- ? spawn(binaryPath, argv, {
94
- stdio: "inherit",
95
- windowsHide: false,
96
- })
97
- : spawn(wrapperPath, argv, { stdio: "inherit" });
98
-
99
- child.on("exit", (code, signal) => {
100
- if (typeof code === "number") {
101
- process.exit(code);
146
+ await ensureInstalled(options);
147
+ } catch (error) {
148
+ errorLog(formatInstallError(error));
149
+ return 1;
102
150
  }
103
151
 
104
- process.exit(signal ? 1 : 0);
105
- });
152
+ return spawnArashi(argv, options);
153
+ }
106
154
 
107
- child.on("error", (error) => {
108
- console.error(`Failed to start arashi. ${error.message}.`);
109
- process.exit(1);
110
- });
155
+ const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
156
+ if (import.meta.url === invokedPath) {
157
+ process.exitCode = await runEntrypoint();
158
+ }
@@ -0,0 +1,211 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, createWriteStream } from "node:fs";
3
+ import { access, constants, mkdir, readFile, rm } from "node:fs/promises";
4
+ import { get } from "node:https";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+
11
+ export const PACKAGE_NAME = "arashi";
12
+ export const GITHUB_REPO = "corwinm/arashi";
13
+ export const MANUAL_INSTALL_URL = `https://github.com/${GITHUB_REPO}/releases`;
14
+
15
+ export class UnsupportedPlatformError extends Error {
16
+ constructor(platform = process.platform, arch = process.arch) {
17
+ super(
18
+ `Unsupported platform: ${platform}-${arch}. Please build from source or file an issue at https://github.com/${GITHUB_REPO}/issues`,
19
+ );
20
+ this.name = "UnsupportedPlatformError";
21
+ this.platform = platform;
22
+ this.arch = arch;
23
+ }
24
+ }
25
+
26
+ export function getPlatformInfo({ platform = process.platform, arch = process.arch } = {}) {
27
+ if (platform === "darwin" && arch === "arm64") {
28
+ return { arch, binaryName: `${PACKAGE_NAME}-macos-arm64`, isWindows: false, platform };
29
+ }
30
+
31
+ if (platform === "linux" && arch === "x64") {
32
+ return { arch, binaryName: `${PACKAGE_NAME}-linux-x64`, isWindows: false, platform };
33
+ }
34
+
35
+ if (platform === "win32" && arch === "x64") {
36
+ return { arch, binaryName: `${PACKAGE_NAME}-windows-x64.exe`, isWindows: true, platform };
37
+ }
38
+
39
+ throw new UnsupportedPlatformError(platform, arch);
40
+ }
41
+
42
+ export function buildReleaseAssetUrl(version, binaryName, repo = GITHUB_REPO) {
43
+ return `https://github.com/${repo}/releases/download/v${version}/${binaryName}`;
44
+ }
45
+
46
+ export async function readPackageVersion(packageJsonPath, { readFileImpl = readFile } = {}) {
47
+ const packageJson = JSON.parse(await readFileImpl(packageJsonPath, "utf8"));
48
+ return packageJson.version;
49
+ }
50
+
51
+ export async function isBinaryInstalled(binaryPath, { accessImpl = access } = {}) {
52
+ try {
53
+ await accessImpl(binaryPath, constants.F_OK);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ export function downloadFile(url, dest, options = {}) {
61
+ const {
62
+ createWriteStreamImpl = createWriteStream,
63
+ getImpl = get,
64
+ log = console.log,
65
+ maxRedirects = 5,
66
+ } = options;
67
+
68
+ return new Promise((resolve, reject) => {
69
+ if (maxRedirects < 0) {
70
+ reject(new Error("Too many redirects while downloading binary"));
71
+ return;
72
+ }
73
+
74
+ log(`Downloading ${url}...`);
75
+
76
+ const file = createWriteStreamImpl(dest);
77
+ const request = getImpl(url, (response) => {
78
+ if (
79
+ response.statusCode === 301 ||
80
+ response.statusCode === 302 ||
81
+ response.statusCode === 307 ||
82
+ response.statusCode === 308
83
+ ) {
84
+ file.close();
85
+ const location = response.headers.location;
86
+ if (!location) {
87
+ reject(new Error(`Redirect response from ${url} did not include a location header`));
88
+ return;
89
+ }
90
+
91
+ downloadFile(location, dest, { ...options, maxRedirects: maxRedirects - 1 })
92
+ .then(resolve)
93
+ .catch(reject);
94
+ return;
95
+ }
96
+
97
+ if (response.statusCode !== 200) {
98
+ file.close();
99
+ reject(new Error(`Failed to download: ${response.statusCode} ${response.statusMessage}`));
100
+ return;
101
+ }
102
+
103
+ response.pipe(file);
104
+ file.on("finish", () => {
105
+ file.close(resolve);
106
+ });
107
+ });
108
+
109
+ request.on("error", (error) => {
110
+ file.close();
111
+ reject(error);
112
+ });
113
+
114
+ file.on("error", (error) => {
115
+ file.close();
116
+ reject(error);
117
+ });
118
+ });
119
+ }
120
+
121
+ export function verifyBinary(binaryPath, { log = console.log, spawnSyncImpl = spawnSync } = {}) {
122
+ const result = spawnSyncImpl(binaryPath, ["--version"], {
123
+ encoding: "utf8",
124
+ stdio: ["ignore", "pipe", "pipe"],
125
+ });
126
+
127
+ if (result.error) {
128
+ throw result.error;
129
+ }
130
+
131
+ if (result.status !== 0) {
132
+ const signalDetail = result.signal ? ` (signal: ${result.signal})` : "";
133
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
134
+ throw new Error(
135
+ `Downloaded binary failed smoke test with exit code ${result.status ?? "unknown"}${signalDetail}${output ? `: ${output}` : ""}`,
136
+ );
137
+ }
138
+
139
+ const versionOutput = (result.stdout ?? "").trim();
140
+ if (!versionOutput) {
141
+ throw new Error("Downloaded binary returned success but produced no version output");
142
+ }
143
+
144
+ log(`✓ Verified ${PACKAGE_NAME} executable (${versionOutput})`);
145
+ return versionOutput;
146
+ }
147
+
148
+ export async function installBinary(options = {}) {
149
+ const rootDir = options.rootDir ?? join(__dirname, "..");
150
+ const binDir = options.binDir ?? join(rootDir, "bin");
151
+ const packageJsonPath = options.packageJsonPath ?? join(rootDir, "package.json");
152
+ const log = options.log ?? console.log;
153
+ const accessImpl = options.accessImpl ?? access;
154
+ const chmodImpl = options.chmodImpl ?? chmodSync;
155
+ const downloadFileImpl = options.downloadFileImpl ?? downloadFile;
156
+ const mkdirImpl = options.mkdirImpl ?? mkdir;
157
+ const readFileImpl = options.readFileImpl ?? readFile;
158
+ const rmImpl = options.rmImpl ?? rm;
159
+ const verifyBinaryImpl = options.verifyBinaryImpl ?? verifyBinary;
160
+
161
+ const version = options.version ?? (await readPackageVersion(packageJsonPath, { readFileImpl }));
162
+ const { binaryName, isWindows } = getPlatformInfo({
163
+ arch: options.arch ?? process.arch,
164
+ platform: options.platform ?? process.platform,
165
+ });
166
+ const binaryPath = join(binDir, binaryName);
167
+ const downloadUrl = buildReleaseAssetUrl(version, binaryName, options.repo ?? GITHUB_REPO);
168
+
169
+ if (!options.force && (await isBinaryInstalled(binaryPath, { accessImpl }))) {
170
+ log(`✓ Binary already installed at ${binaryPath}`);
171
+ return { binaryName, binaryPath, downloadUrl, status: "already-installed", version };
172
+ }
173
+
174
+ try {
175
+ await mkdirImpl(binDir, { recursive: true });
176
+ await downloadFileImpl(downloadUrl, binaryPath, { log });
177
+
178
+ if (!isWindows) {
179
+ chmodImpl(binaryPath, 0o755);
180
+ }
181
+
182
+ verifyBinaryImpl(binaryPath, { log });
183
+
184
+ log(`✓ Successfully installed ${PACKAGE_NAME} v${version}`);
185
+ log(` Binary location: ${binaryPath}`);
186
+ return { binaryName, binaryPath, downloadUrl, status: "installed", version };
187
+ } catch (error) {
188
+ await rmImpl(binaryPath, { force: true }).catch(() => {});
189
+ throw error;
190
+ }
191
+ }
192
+
193
+ export function formatInstallError(error) {
194
+ const message = error instanceof Error ? error.message : String(error);
195
+ return [
196
+ `✗ Failed to install ${PACKAGE_NAME}: ${message}`,
197
+ "",
198
+ `You can manually download binaries from: ${MANUAL_INSTALL_URL}`,
199
+ ].join("\n");
200
+ }
201
+
202
+ export async function runInstallCli(options = {}) {
203
+ const errorLog = options.error ?? console.error;
204
+ try {
205
+ await installBinary(options);
206
+ return 0;
207
+ } catch (error) {
208
+ errorLog(formatInstallError(error));
209
+ return 1;
210
+ }
211
+ }
package/bin/update.js ADDED
@@ -0,0 +1,323 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import { getPlatformInfo, installBinary, MANUAL_INSTALL_URL, PACKAGE_NAME } from "./install-binary.js";
7
+
8
+ export const UPDATE_COMMAND_DESCRIPTION = "Check for and apply Arashi updates";
9
+
10
+ export function parseUpdateArgs(argv = []) {
11
+ return {
12
+ check: argv.includes("--check"),
13
+ dryRun: argv.includes("--dry-run"),
14
+ yes: argv.includes("--yes") || argv.includes("-y"),
15
+ };
16
+ }
17
+
18
+ function normalizeVersion(version) {
19
+ return String(version ?? "").trim().replace(/^v/, "").split("+")[0];
20
+ }
21
+
22
+ export function compareVersions(a, b) {
23
+ const left = normalizeVersion(a).split(/[.-]/);
24
+ const right = normalizeVersion(b).split(/[.-]/);
25
+ const length = Math.max(left.length, right.length);
26
+
27
+ for (let index = 0; index < length; index += 1) {
28
+ const leftPart = left[index] ?? "0";
29
+ const rightPart = right[index] ?? "0";
30
+ const leftNumber = Number(leftPart);
31
+ const rightNumber = Number(rightPart);
32
+
33
+ if (Number.isInteger(leftNumber) && Number.isInteger(rightNumber)) {
34
+ if (leftNumber > rightNumber) return 1;
35
+ if (leftNumber < rightNumber) return -1;
36
+ continue;
37
+ }
38
+
39
+ if (leftPart === rightPart) continue;
40
+ if (leftPart === "") return 1;
41
+ if (rightPart === "") return -1;
42
+ return leftPart > rightPart ? 1 : -1;
43
+ }
44
+
45
+ return 0;
46
+ }
47
+
48
+ export async function readPackageMetadata(rootDir, { readFileImpl = readFile } = {}) {
49
+ const packageJsonPath = join(rootDir, "package.json");
50
+ const packageJson = JSON.parse(await readFileImpl(packageJsonPath, "utf8"));
51
+ return { name: packageJson.name, packageJsonPath, version: packageJson.version };
52
+ }
53
+
54
+ export function detectNpmManagedInstall({ rootDir, metadata } = {}) {
55
+ if (!rootDir || metadata?.name !== PACKAGE_NAME || !metadata?.version) {
56
+ return { method: "ambiguous" };
57
+ }
58
+
59
+ return { method: "npm-managed", rootDir, version: metadata.version };
60
+ }
61
+
62
+ export async function fetchLatestPackageVersion({ fetchImpl = fetch, packageName = PACKAGE_NAME } = {}) {
63
+ const response = await fetchImpl(`https://registry.npmjs.org/${packageName}/latest`, {
64
+ headers: { accept: "application/json" },
65
+ });
66
+
67
+ if (!response.ok) {
68
+ throw new Error(`npm registry returned ${response.status} ${response.statusText}`.trim());
69
+ }
70
+
71
+ const metadata = await response.json();
72
+ if (!metadata.version) {
73
+ throw new Error("npm registry response did not include a version");
74
+ }
75
+
76
+ return metadata.version;
77
+ }
78
+
79
+ export async function fetchLatestGitHubRelease({ fetchImpl = fetch, repo = "corwinm/arashi" } = {}) {
80
+ const response = await fetchImpl(`https://api.github.com/repos/${repo}/releases/latest`, {
81
+ headers: { accept: "application/vnd.github+json" },
82
+ });
83
+
84
+ if (!response.ok) {
85
+ throw new Error(`GitHub releases returned ${response.status} ${response.statusText}`.trim());
86
+ }
87
+
88
+ const release = await response.json();
89
+ const version = normalizeVersion(release.tag_name ?? release.name);
90
+ if (!version) {
91
+ throw new Error("GitHub release response did not include a tag");
92
+ }
93
+
94
+ return {
95
+ htmlUrl: release.html_url ?? MANUAL_INSTALL_URL,
96
+ version,
97
+ };
98
+ }
99
+
100
+ export function selectPackageManagerCommand({ env = process.env, rootDir } = {}) {
101
+ const userAgent = env.npm_config_user_agent ?? "";
102
+ const execPath = env.npm_execpath ?? "";
103
+ const combined = `${userAgent} ${execPath}`.toLowerCase();
104
+ const normalizedRootDir = String(rootDir ?? "").toLowerCase();
105
+ const looksLikeVitePlus =
106
+ combined.includes("vite-plus") ||
107
+ /(^|[\\/\s])vp(?:\.exe)?($|[\\/\s])/.test(combined) ||
108
+ /(^|[\\/])\.vite-plus([\\/]|$)/.test(normalizedRootDir);
109
+
110
+ if (looksLikeVitePlus) {
111
+ return { args: ["update", "-g", PACKAGE_NAME], command: "vp", label: "Vite+" };
112
+ }
113
+
114
+ if (combined.includes("pnpm")) {
115
+ return { args: ["add", "-g", `${PACKAGE_NAME}@latest`], command: "pnpm", label: "pnpm" };
116
+ }
117
+
118
+ if (combined.includes("yarn")) {
119
+ return { args: ["global", "add", `${PACKAGE_NAME}@latest`], command: "yarn", label: "yarn" };
120
+ }
121
+
122
+ if (combined.includes("bun")) {
123
+ return { args: ["add", "-g", `${PACKAGE_NAME}@latest`], command: "bun", label: "bun" };
124
+ }
125
+
126
+ if (combined.includes("npm")) {
127
+ return { args: ["install", "-g", `${PACKAGE_NAME}@latest`], command: "npm", label: "npm" };
128
+ }
129
+
130
+ return null;
131
+ }
132
+
133
+ export function formatManualUpdateGuidance(latestVersion, platformInfo) {
134
+ const lines = [
135
+ "Automatic update is not available for this install method.",
136
+ `Latest release: v${latestVersion}`,
137
+ `Manual releases: ${MANUAL_INSTALL_URL}`,
138
+ ];
139
+
140
+ if (platformInfo?.binaryName) {
141
+ lines.push(`Download asset for this platform: ${platformInfo.binaryName}`);
142
+ }
143
+
144
+ lines.push("Replace the existing arashi binary with the downloaded asset and make it executable if needed.");
145
+ return lines.join("\n");
146
+ }
147
+
148
+ export function formatSupportedPackageManagerGuidance() {
149
+ return [
150
+ "Could not confidently detect the package manager used for this npm-managed install.",
151
+ "Run one of these manually if it matches how you installed Arashi:",
152
+ ` npm install -g ${PACKAGE_NAME}@latest`,
153
+ ` pnpm add -g ${PACKAGE_NAME}@latest`,
154
+ ` yarn global add ${PACKAGE_NAME}@latest`,
155
+ ` bun add -g ${PACKAGE_NAME}@latest`,
156
+ ` vp update -g ${PACKAGE_NAME}`,
157
+ ].join("\n");
158
+ }
159
+
160
+ export async function defaultConfirmPrompt(message) {
161
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
162
+ try {
163
+ const answer = await rl.question(message);
164
+ return /^(y|yes)$/i.test(answer.trim());
165
+ } finally {
166
+ rl.close();
167
+ }
168
+ }
169
+
170
+ async function confirmUpdate({ promptImpl = defaultConfirmPrompt, yes }) {
171
+ if (yes) return true;
172
+ if (!process.stdin.isTTY) return false;
173
+ return promptImpl("Update Arashi now? [y/N] ");
174
+ }
175
+
176
+ function defaultRootDir(binDir) {
177
+ return join(binDir ?? fileURLToPath(new URL(".", import.meta.url)), "..");
178
+ }
179
+
180
+ export async function runNpmManagedUpdate(argv = [], options = {}) {
181
+ const flags = parseUpdateArgs(argv);
182
+ const log = options.log ?? console.log;
183
+ const errorLog = options.error ?? console.error;
184
+ const rootDir = options.rootDir ?? defaultRootDir(options.binDir);
185
+ let metadata;
186
+
187
+ try {
188
+ metadata = options.metadata ?? (await readPackageMetadata(rootDir, options));
189
+ } catch (error) {
190
+ errorLog(`Failed to read package metadata: ${error instanceof Error ? error.message : String(error)}`);
191
+ errorLog(formatSupportedPackageManagerGuidance());
192
+ return 1;
193
+ }
194
+
195
+ const install = detectNpmManagedInstall({ metadata, rootDir });
196
+ if (install.method !== "npm-managed") {
197
+ log(formatSupportedPackageManagerGuidance());
198
+ return 0;
199
+ }
200
+
201
+ let latestVersion;
202
+ try {
203
+ latestVersion = options.latestVersion ?? (await fetchLatestPackageVersion(options));
204
+ } catch (error) {
205
+ errorLog(`Failed to check latest ${PACKAGE_NAME} version: ${error instanceof Error ? error.message : String(error)}`);
206
+ errorLog(`Manual releases: ${MANUAL_INSTALL_URL}`);
207
+ return 1;
208
+ }
209
+
210
+ if (compareVersions(metadata.version, latestVersion) >= 0) {
211
+ log(`${PACKAGE_NAME} is already up to date (v${metadata.version}).`);
212
+ return 0;
213
+ }
214
+
215
+ const packageManager = options.packageManager ?? selectPackageManagerCommand(options);
216
+ log(`Update available: ${PACKAGE_NAME} v${metadata.version} -> v${latestVersion}`);
217
+
218
+ if (flags.check) {
219
+ log("Check only: no changes made.");
220
+ return 0;
221
+ }
222
+
223
+ if (!packageManager) {
224
+ log(formatSupportedPackageManagerGuidance());
225
+ return 0;
226
+ }
227
+
228
+ const renderedCommand = [packageManager.command, ...packageManager.args].join(" ");
229
+ log(`Selected update command: ${renderedCommand}`);
230
+ log("Binary refresh: reinstall the matching platform binary after the package update.");
231
+
232
+ if (flags.dryRun) {
233
+ log("Dry run: no changes made.");
234
+ return 0;
235
+ }
236
+
237
+ if (!(await confirmUpdate({ promptImpl: options.promptImpl, yes: flags.yes }))) {
238
+ errorLog("Update skipped. Rerun with --yes for non-interactive updates or use --dry-run to inspect the plan.");
239
+ return 1;
240
+ }
241
+
242
+ const spawnSyncImpl = options.spawnSyncImpl ?? spawnSync;
243
+ const result = spawnSyncImpl(packageManager.command, packageManager.args, {
244
+ cwd: rootDir,
245
+ encoding: "utf8",
246
+ stdio: "inherit",
247
+ });
248
+
249
+ if (result.error) {
250
+ errorLog(`Package manager failed to start: ${result.error.message}`);
251
+ return 1;
252
+ }
253
+
254
+ if (result.status !== 0) {
255
+ errorLog(`Package manager update failed with exit code ${result.status ?? "unknown"}. Existing binary was not removed.`);
256
+ return 1;
257
+ }
258
+
259
+ let updatedMetadata = metadata;
260
+ try {
261
+ updatedMetadata = await readPackageMetadata(rootDir, options);
262
+ } catch {
263
+ updatedMetadata = { ...metadata, version: latestVersion };
264
+ }
265
+
266
+ try {
267
+ const installBinaryImpl = options.installBinaryImpl ?? installBinary;
268
+ const installResult = await installBinaryImpl({
269
+ ...options,
270
+ binDir: options.binDir,
271
+ force: true,
272
+ rootDir,
273
+ version: updatedMetadata.version,
274
+ });
275
+ log(`✓ Updated ${PACKAGE_NAME} from v${metadata.version} to v${updatedMetadata.version}.`);
276
+ log(` Package-manager command: ${renderedCommand}`);
277
+ log(` Binary location: ${installResult.binaryPath ?? "installed platform binary"}`);
278
+ return 0;
279
+ } catch (error) {
280
+ errorLog(`Package updated, but binary refresh failed: ${error instanceof Error ? error.message : String(error)}`);
281
+ errorLog("Run `arashi install` to retry the binary installation, or download a release manually.");
282
+ return 1;
283
+ }
284
+ }
285
+
286
+ export async function runDirectBinaryUpdate(argv = [], options = {}) {
287
+ const flags = parseUpdateArgs(argv);
288
+ const log = options.log ?? console.log;
289
+ const errorLog = options.error ?? console.error;
290
+ const currentVersion = options.currentVersion;
291
+
292
+ let release;
293
+ try {
294
+ release = options.latestRelease ?? (await fetchLatestGitHubRelease(options));
295
+ } catch (error) {
296
+ errorLog(`Failed to check latest ${PACKAGE_NAME} release: ${error instanceof Error ? error.message : String(error)}`);
297
+ errorLog(`Manual releases: ${MANUAL_INSTALL_URL}`);
298
+ return 1;
299
+ }
300
+
301
+ let platformInfo;
302
+ try {
303
+ platformInfo = getPlatformInfo({ arch: options.arch, platform: options.platform });
304
+ } catch {
305
+ platformInfo = undefined;
306
+ }
307
+
308
+ if (currentVersion && compareVersions(currentVersion, release.version) >= 0) {
309
+ log(`${PACKAGE_NAME} direct binary is already current (v${currentVersion}).`);
310
+ return 0;
311
+ }
312
+
313
+ if (currentVersion) {
314
+ log(`Update available: ${PACKAGE_NAME} v${currentVersion} -> v${release.version}`);
315
+ } else {
316
+ log(`Latest ${PACKAGE_NAME} release: v${release.version}`);
317
+ }
318
+ log(formatManualUpdateGuidance(release.version, platformInfo));
319
+ if (release.htmlUrl) log(`Release URL: ${release.htmlUrl}`);
320
+ if (flags.check) log("Check only: no changes made.");
321
+ if (flags.dryRun) log("Dry run: no changes made.");
322
+ return 0;
323
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arashi",
3
- "version": "1.11.3",
3
+ "version": "1.13.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",
@@ -29,9 +29,10 @@
29
29
  "files": [
30
30
  "bin/arashi",
31
31
  "bin/arashi.js",
32
+ "bin/install-binary.js",
33
+ "bin/update.js",
32
34
  "bin/arashi.bat",
33
35
  "bin/arashi.ps1",
34
- "scripts/postinstall.js",
35
36
  "schema/config.schema.json",
36
37
  "README.md",
37
38
  "LICENSE"
@@ -65,7 +66,6 @@
65
66
  "format": "oxfmt --config .oxfmtrc.json --write .",
66
67
  "format:check": "oxfmt --config .oxfmtrc.json --check .",
67
68
  "quality:changed": "bun run scripts/quality/changed-files-quality.ts",
68
- "postinstall": "node scripts/postinstall.js",
69
69
  "prepublishOnly": "bun run schema:publish",
70
70
  "prepare": "husky"
71
71
  },
@@ -1,176 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Post-install script to download the platform-specific arashi binary from GitHub releases
5
- * This keeps the npm package size small while providing pre-compiled binaries
6
- */
7
-
8
- import { access, constants, mkdir, readFile, rm } from "node:fs/promises";
9
- import { chmodSync, createWriteStream } from "node:fs";
10
- import { dirname, join } from "node:path";
11
- import { fileURLToPath } from "node:url";
12
- import { get } from "node:https";
13
- import { spawnSync } from "node:child_process";
14
-
15
- const __filename = fileURLToPath(import.meta.url);
16
- const __dirname = dirname(__filename);
17
-
18
- const PACKAGE_NAME = "arashi";
19
- const GITHUB_REPO = "corwinm/arashi";
20
-
21
- // Determine platform and binary name
22
- function getPlatformInfo() {
23
- const { platform } = process;
24
- const { arch } = process;
25
-
26
- let binaryName = "";
27
- if (platform === "darwin" && arch === "arm64") {
28
- binaryName = `${PACKAGE_NAME}-macos-arm64`;
29
- } else if (platform === "linux" && arch === "x64") {
30
- binaryName = `${PACKAGE_NAME}-linux-x64`;
31
- } else if (platform === "win32" && arch === "x64") {
32
- binaryName = `${PACKAGE_NAME}-windows-x64.exe`;
33
- } else {
34
- throw new Error(
35
- `Unsupported platform: ${platform}-${arch}. Please build from source or file an issue at https://github.com/${GITHUB_REPO}/issues`,
36
- );
37
- }
38
-
39
- return { binaryName, isWindows: platform === "win32" };
40
- }
41
-
42
- // Download file from URL
43
- function downloadFile(url, dest) {
44
- return new Promise((resolve, reject) => {
45
- console.log(`Downloading ${url}...`);
46
-
47
- const file = createWriteStream(dest);
48
- const request = get(url, (response) => {
49
- // Handle redirects
50
- if (
51
- response.statusCode === 301 ||
52
- response.statusCode === 302 ||
53
- response.statusCode === 307
54
- ) {
55
- file.close();
56
- downloadFile(response.headers.location, dest).then(resolve).catch(reject);
57
- return;
58
- }
59
-
60
- if (response.statusCode !== 200) {
61
- file.close();
62
- reject(new Error(`Failed to download: ${response.statusCode} ${response.statusMessage}`));
63
- return;
64
- }
65
-
66
- response.pipe(file);
67
-
68
- file.on("finish", () => {
69
- file.close(resolve);
70
- });
71
- });
72
-
73
- request.on("error", (err) => {
74
- file.close();
75
- reject(err);
76
- });
77
-
78
- file.on("error", (err) => {
79
- file.close();
80
- reject(err);
81
- });
82
- });
83
- }
84
-
85
- function verifyBinary(binaryPath) {
86
- const result = spawnSync(binaryPath, ["--version"], {
87
- encoding: "utf8",
88
- stdio: ["ignore", "pipe", "pipe"],
89
- });
90
-
91
- if (result.error) {
92
- throw result.error;
93
- }
94
-
95
- if (result.status !== 0) {
96
- const signalDetail = result.signal ? ` (signal: ${result.signal})` : "";
97
- const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
98
- throw new Error(
99
- `Downloaded binary failed smoke test with exit code ${result.status ?? "unknown"}${signalDetail}${output ? `: ${output}` : ""}`,
100
- );
101
- }
102
-
103
- const versionOutput = (result.stdout ?? "").trim();
104
- if (!versionOutput) {
105
- throw new Error("Downloaded binary returned success but produced no version output");
106
- }
107
-
108
- console.log(`✓ Verified ${PACKAGE_NAME} executable (${versionOutput})`);
109
- }
110
-
111
- // Main installation logic
112
- async function install() {
113
- let binaryPath = "";
114
-
115
- try {
116
- // Skip postinstall in development (when installing from the repo)
117
- // Check if we're in development by looking for src/ directory
118
- const srcDir = join(__dirname, "..", "src");
119
- try {
120
- await access(srcDir, constants.F_OK);
121
- console.log("✓ Development environment detected, skipping binary download");
122
- console.log(" Run 'bun run build' to build binary locally");
123
- return;
124
- } catch {
125
- // Not in development, continue with download
126
- }
127
-
128
- // Get version from package.json
129
- const packageJsonPath = join(__dirname, "..", "package.json");
130
- const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
131
- const { version } = packageJson;
132
-
133
- const { binaryName, isWindows } = getPlatformInfo();
134
- const binDir = join(__dirname, "..", "bin");
135
- binaryPath = join(binDir, binaryName);
136
-
137
- // Check if binary already exists
138
- try {
139
- await access(binaryPath, constants.F_OK);
140
- console.log(`✓ Binary already exists at ${binaryPath}`);
141
- return;
142
- } catch {
143
- // Binary doesn't exist, continue with download
144
- }
145
-
146
- // Ensure bin directory exists
147
- await mkdir(binDir, { recursive: true });
148
-
149
- // Download binary from GitHub releases
150
- const downloadUrl = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${binaryName}`;
151
-
152
- await downloadFile(downloadUrl, binaryPath);
153
-
154
- // Make binary executable (Unix-like systems)
155
- if (!isWindows) {
156
- chmodSync(binaryPath, 0o755);
157
- }
158
-
159
- verifyBinary(binaryPath);
160
-
161
- console.log(`✓ Successfully installed ${PACKAGE_NAME} v${version}`);
162
- console.log(` Binary location: ${binaryPath}`);
163
- } catch (error) {
164
- if (binaryPath) {
165
- await rm(binaryPath, { force: true }).catch(() => {});
166
- }
167
-
168
- console.error(`✗ Failed to install ${PACKAGE_NAME}:`, error.message);
169
- console.error(
170
- `\nYou can manually download binaries from: https://github.com/${GITHUB_REPO}/releases`,
171
- );
172
- process.exit(1);
173
- }
174
- }
175
-
176
- await install();