arashi 1.12.0 → 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
@@ -55,6 +55,16 @@ To preinstall the binary explicitly, run:
55
55
  arashi install
56
56
  ```
57
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
+
58
68
  Verify install:
59
69
 
60
70
  ```bash
@@ -103,6 +113,7 @@ Arashi currently provides these commands:
103
113
 
104
114
  - `arashi init`
105
115
  - `arashi install`
116
+ - `arashi update [--check] [--dry-run] [--yes]`
106
117
  - `arashi add <git-url>`
107
118
  - `arashi clone [--all]`
108
119
  - `arashi create <branch>`
package/bin/arashi.js CHANGED
@@ -4,6 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { formatInstallError, getPlatformInfo, installBinary } from "./install-binary.js";
7
+ import { runNpmManagedUpdate } from "./update.js";
7
8
 
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = dirname(__filename);
@@ -79,6 +80,10 @@ export function isExplicitInstallCommand(argv) {
79
80
  return argv[0] === "install";
80
81
  }
81
82
 
83
+ export function isExplicitUpdateCommand(argv) {
84
+ return argv[0] === "update";
85
+ }
86
+
82
87
  async function runExplicitInstall(options) {
83
88
  const binDir = options.binDir ?? __dirname;
84
89
  const rootDir = options.rootDir ?? join(binDir, "..");
@@ -134,6 +139,10 @@ export async function runEntrypoint(argv = process.argv.slice(2), options = {})
134
139
  return 0;
135
140
  }
136
141
 
142
+ if (isExplicitUpdateCommand(argv)) {
143
+ return runNpmManagedUpdate(argv.slice(1), options);
144
+ }
145
+
137
146
  await ensureInstalled(options);
138
147
  } catch (error) {
139
148
  errorLog(formatInstallError(error));
@@ -166,7 +166,7 @@ export async function installBinary(options = {}) {
166
166
  const binaryPath = join(binDir, binaryName);
167
167
  const downloadUrl = buildReleaseAssetUrl(version, binaryName, options.repo ?? GITHUB_REPO);
168
168
 
169
- if (await isBinaryInstalled(binaryPath, { accessImpl })) {
169
+ if (!options.force && (await isBinaryInstalled(binaryPath, { accessImpl }))) {
170
170
  log(`✓ Binary already installed at ${binaryPath}`);
171
171
  return { binaryName, binaryPath, downloadUrl, status: "already-installed", version };
172
172
  }
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.12.0",
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",
@@ -30,6 +30,7 @@
30
30
  "bin/arashi",
31
31
  "bin/arashi.js",
32
32
  "bin/install-binary.js",
33
+ "bin/update.js",
33
34
  "bin/arashi.bat",
34
35
  "bin/arashi.ps1",
35
36
  "schema/config.schema.json",