@crystalplatforms/adm 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +154 -161
- package/bin/adm +18 -21
- package/package.json +9 -22
- package/scripts/completion/adm.bash +12 -55
- package/scripts/completion/adm.zsh +21 -58
- package/scripts/install.js +8 -8
- package/src/commands/config.js +66 -0
- package/src/commands/dotfiles.js +49 -32
- package/src/commands/feedback.js +12 -0
- package/src/commands/help.js +33 -0
- package/src/commands/uninstall.js +73 -54
- package/src/commands/version.js +7 -0
- package/src/commands.js +26 -0
- package/src/config/index.js +32 -32
- package/src/data/extensions.json +358 -358
- package/src/engines.js +38 -0
- package/src/install/executor.js +263 -0
- package/src/install/hints.js +94 -0
- package/src/install/npm-prefix.js +110 -0
- package/src/install/plan.js +75 -0
- package/src/install/report.js +46 -0
- package/src/install/run.js +88 -0
- package/src/install/shell-paths.js +129 -0
- package/src/legacy-cleanup.js +96 -0
- package/src/pkg/builders.js +220 -0
- package/src/pkg/managers.js +59 -0
- package/src/pkg/package-names.js +229 -0
- package/src/pkg/script-urls.js +100 -0
- package/src/platform/index.js +129 -0
- package/src/platform/os-release.js +27 -0
- package/src/platform/shell.js +111 -0
- package/src/reports.js +37 -0
- package/src/router.js +157 -0
- package/src/setup/catalog.js +13 -13
- package/src/setup/git-step.js +128 -0
- package/src/setup/loader.js +108 -0
- package/src/setup/prompts.js +259 -0
- package/src/setup/registry.js +48 -0
- package/src/setup/spinner.js +67 -0
- package/src/setup/wizard.js +319 -195
- package/src/upgrade/index.js +499 -0
- package/src/utils/closest-match.js +36 -0
- package/src/utils/dotfiles-sync.js +89 -89
- package/src/utils/file-conflict-handler.js +30 -30
- package/bin/adm-tui +0 -13
- package/src/ai/knowledge.js +0 -61
- package/src/commands/connect.js +0 -76
- package/src/commands/issue.js +0 -80
- package/src/commands/pr.js +0 -55
- package/src/commands/theme.js +0 -103
- package/src/commands/upgrade.js +0 -114
- package/src/installer/git-config.js +0 -34
- package/src/installer/node-installer.js +0 -50
- package/src/installer/package-manager-installer.js +0 -31
- package/src/installer/ssh-setup.js +0 -31
- package/src/installer/system-packages.js +0 -49
- package/src/installers/index.js +0 -66
- package/src/integrations/ai-backend.js +0 -60
- package/src/integrations/ai-providers/registry.js +0 -261
- package/src/integrations/github.js +0 -107
- package/src/integrations/gitlab.js +0 -121
- package/src/plugins/context.js +0 -64
- package/src/plugins/loader.js +0 -50
- package/src/plugins/runner.js +0 -25
- package/src/setup/installer.js +0 -192
- package/src/shell/detector.js +0 -10
- package/src/tui/app-state.js +0 -374
- package/src/tui/app.js +0 -442
- package/src/tui/autocomplete.js +0 -57
- package/src/tui/commands/registry.js +0 -823
- package/src/tui/components/SetupScreen.js +0 -292
- package/src/ui/animations.js +0 -24
- package/src/ui/spinner.js +0 -34
- package/src/ui/theme/cyberpunk.js +0 -14
- package/src/ui/theme/dark.js +0 -14
- package/src/ui/theme/forest.js +0 -14
- package/src/ui/theme/light.js +0 -14
- package/src/ui/theme/monokai.js +0 -14
- package/src/ui/theme/nord.js +0 -14
- package/src/ui/theme.js +0 -49
- package/src/utils/command-history.js +0 -43
- package/src/utils/error-handler.js +0 -69
- package/src/utils/fuzzy-search.js +0 -20
- package/src/utils/github-error-handler.js +0 -26
- package/src/utils/keychain.js +0 -97
- package/src/utils/open-url.js +0 -15
- package/src/utils/terminal-detection.js +0 -26
- package/src/utils/token-encryption.js +0 -96
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
// Self-update (#32): `adm upgrade` / `adm update` swaps the portable bundle
|
|
2
|
+
// in ~/.adm (app + private runtime) for the latest GitHub Release.
|
|
3
|
+
//
|
|
4
|
+
// adm upgrade --check read-only: is a newer release available? (exit 0 both ways)
|
|
5
|
+
// adm upgrade --dry-run print the plan (asset, url, sha256, target) — no changes
|
|
6
|
+
// adm upgrade download → verify sha256 → atomic swap with rollback
|
|
7
|
+
// adm update alias of `adm upgrade`
|
|
8
|
+
//
|
|
9
|
+
// Install-channel detection:
|
|
10
|
+
// bundle (~/.adm/app/package.json exists) → self-swap (this module)
|
|
11
|
+
// npm (everything else) → print the `npm update -g` command
|
|
12
|
+
//
|
|
13
|
+
// Atomic swap (AC): download to a tmp dir → verify sha256 BEFORE touching the
|
|
14
|
+
// install → back up the old app/runtime (rename) → move the new ones in → on
|
|
15
|
+
// ANY failure rename the backups back (rollback) → old install intact, exit 2.
|
|
16
|
+
//
|
|
17
|
+
// All I/O is injectable (fetchFn, fs, execFileFn, home, env, apiBase,
|
|
18
|
+
// downloadBase, spinnerFactory) so the whole flow runs against a fixture
|
|
19
|
+
// "releases server" in tests — never against production.
|
|
20
|
+
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const { Readable } = require('stream');
|
|
26
|
+
const { getVersion } = require('../commands/version');
|
|
27
|
+
const { loader } = require('../setup/loader');
|
|
28
|
+
const { saveReport } = require('../reports');
|
|
29
|
+
|
|
30
|
+
const REPO = 'CrystalPlatforms/ADM-CLI';
|
|
31
|
+
const NPM_PACKAGE = '@crystalplatforms/adm';
|
|
32
|
+
const API_BASE = `https://api.github.com/repos/${REPO}`;
|
|
33
|
+
const DOWNLOAD_BASE = `https://github.com/${REPO}/releases/download`;
|
|
34
|
+
const USER_AGENT = 'adm-cli';
|
|
35
|
+
|
|
36
|
+
// ── channel + paths ────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
// The bundle lives in ~/.adm (unix) or %LOCALAPPDATA%\adm (win32).
|
|
39
|
+
// ADM_HOME overrides both (same var the installers honor).
|
|
40
|
+
function admHomeDir({ env = process.env, home = os.homedir(), platform = process.platform } = {}) {
|
|
41
|
+
if (env.ADM_HOME) return env.ADM_HOME;
|
|
42
|
+
if (platform === 'win32') return path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'adm');
|
|
43
|
+
return path.join(home, '.adm');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Detects how this ADM was installed. Explicit ADM_CHANNEL wins (tests /
|
|
47
|
+
// edge cases), then the on-disk layout: the bundle markers is
|
|
48
|
+
// <admHome>/app/package.json — npm installs never create that.
|
|
49
|
+
function detectChannel(opts = {}) {
|
|
50
|
+
const env = opts.env || process.env;
|
|
51
|
+
const admHome = opts.admHome || admHomeDir(opts);
|
|
52
|
+
const fsMod = opts.fs || fs;
|
|
53
|
+
|
|
54
|
+
if (env.ADM_CHANNEL === 'npm') return { channel: 'npm', admHome };
|
|
55
|
+
if (env.ADM_CHANNEL === 'bundle') return { channel: 'bundle', admHome };
|
|
56
|
+
|
|
57
|
+
const appPkg = path.join(admHome, 'app', 'package.json');
|
|
58
|
+
try {
|
|
59
|
+
fsMod.accessSync(appPkg);
|
|
60
|
+
return { channel: 'bundle', admHome };
|
|
61
|
+
} catch {
|
|
62
|
+
return { channel: 'npm', admHome };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── versions ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
// Numeric semver compare for x.y.z release tags ("v" prefix tolerated).
|
|
69
|
+
// Returns <0 / 0 / >0. Non-numeric segments compare as strings (safe
|
|
70
|
+
// fallback); missing segments count as 0 ("1.2" < "1.2.1").
|
|
71
|
+
function compareVersions(a, b) {
|
|
72
|
+
const pa = String(a).replace(/^v/, '').split('.');
|
|
73
|
+
const pb = String(b).replace(/^v/, '').split('.');
|
|
74
|
+
const len = Math.max(pa.length, pb.length);
|
|
75
|
+
for (let i = 0; i < len; i++) {
|
|
76
|
+
const sa = pa[i] || '0';
|
|
77
|
+
const sb = pb[i] || '0';
|
|
78
|
+
const na = /^\d+$/.test(sa) ? Number(sa) : NaN;
|
|
79
|
+
const nb = /^\d+$/.test(sb) ? Number(sb) : NaN;
|
|
80
|
+
if (!Number.isNaN(na) && !Number.isNaN(nb)) {
|
|
81
|
+
if (na !== nb) return na - nb;
|
|
82
|
+
} else if (sa !== sb) {
|
|
83
|
+
return sa < sb ? -1 : 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Latest release version from the GitHub API (tag_name, "v" stripped).
|
|
90
|
+
// Throws on any non-200 / malformed payload.
|
|
91
|
+
async function fetchLatestVersion({ apiBase = API_BASE, fetchFn } = {}) {
|
|
92
|
+
const res = await fetchFn(`${apiBase}/releases/latest`, {
|
|
93
|
+
headers: { 'User-Agent': USER_AGENT, Accept: 'application/vnd.github+json' },
|
|
94
|
+
signal: AbortSignal.timeout(30 * 1000),
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) throw new Error(`cannot resolve the latest release (HTTP ${res.status})`);
|
|
97
|
+
const data = await res.json();
|
|
98
|
+
const tag = data && data.tag_name;
|
|
99
|
+
if (!tag) throw new Error('release payload has no tag_name');
|
|
100
|
+
return String(tag).replace(/^v/, '');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The platform bundle target: linux-x64, linux-arm64, darwin-x64,
|
|
104
|
+
// darwin-arm64, win32-x64. Throws for unsupported combos — self-update never
|
|
105
|
+
// guesses.
|
|
106
|
+
function targetFor(platform = process.platform, arch = process.arch) {
|
|
107
|
+
const t = `${platform}-${arch}`;
|
|
108
|
+
const KNOWN = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'win32-x64'];
|
|
109
|
+
if (!KNOWN.includes(t)) {
|
|
110
|
+
throw new Error(`no bundle available for ${t} — update manually: https://github.com/${REPO}/releases`);
|
|
111
|
+
}
|
|
112
|
+
return t;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function assetName(version, target) {
|
|
116
|
+
return `adm-v${version}-${target}.tar.gz`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── download + verify ───────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
// Streams url to dest, reporting { received, total } through onProgress.
|
|
122
|
+
async function downloadFile(url, dest, { fetchFn = global.fetch, onProgress, timeoutMs = 15 * 60 * 1000 } = {}) {
|
|
123
|
+
const res = await fetchFn(url, {
|
|
124
|
+
headers: { 'User-Agent': USER_AGENT },
|
|
125
|
+
redirect: 'follow',
|
|
126
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
127
|
+
});
|
|
128
|
+
if (!res.ok) throw new Error(`download failed (HTTP ${res.status}): ${url}`);
|
|
129
|
+
if (!res.body) throw new Error(`download failed (empty body): ${url}`);
|
|
130
|
+
const total = Number(res.headers.get('content-length')) || 0;
|
|
131
|
+
let received = 0;
|
|
132
|
+
const out = fs.createWriteStream(dest);
|
|
133
|
+
await new Promise((resolve, reject) => {
|
|
134
|
+
const reader = Readable.fromWeb(res.body);
|
|
135
|
+
reader.on('error', reject);
|
|
136
|
+
reader.on('data', chunk => {
|
|
137
|
+
received += chunk.length;
|
|
138
|
+
if (onProgress) onProgress({ received, total });
|
|
139
|
+
});
|
|
140
|
+
out.on('error', reject);
|
|
141
|
+
out.on('finish', resolve);
|
|
142
|
+
reader.pipe(out);
|
|
143
|
+
});
|
|
144
|
+
return dest;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Same streaming hash as scripts/build-bundle.js — no sha256sum CLI (Windows
|
|
148
|
+
// has none).
|
|
149
|
+
function sha256File(file, fsMod = fs) {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
const hash = crypto.createHash('sha256');
|
|
152
|
+
const stream = fsMod.createReadStream
|
|
153
|
+
? fsMod.createReadStream(file)
|
|
154
|
+
: require('fs').createReadStream(file);
|
|
155
|
+
stream.on('error', reject);
|
|
156
|
+
stream.on('data', chunk => hash.update(chunk));
|
|
157
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Parses "<hash> <name>" checksum files (first token wins).
|
|
162
|
+
function parseSha256(text) {
|
|
163
|
+
const line = String(text).trim().split('\n')[0].trim();
|
|
164
|
+
const m = line.match(/^([0-9a-fA-F]{64})\b/);
|
|
165
|
+
return m ? m[1].toLowerCase() : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── unpack ──────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
// Gunzip via Node zlib, then `tar xf <relative-name>` from inside the unpack
|
|
171
|
+
// dir — the exact pairing proven by scripts/build-bundle.js on every
|
|
172
|
+
// platform (GNU tar rejects "C:\…" argument paths as remote hosts).
|
|
173
|
+
function unpackBundle(archive, unpackDir, { execFileFn } = {}) {
|
|
174
|
+
const execFile = execFileFn || require('child_process').execFile;
|
|
175
|
+
const tarFile = path.join(unpackDir, 'bundle.tar');
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const input = fs.createReadStream(archive);
|
|
178
|
+
const output = fs.createWriteStream(tarFile);
|
|
179
|
+
input.on('error', reject);
|
|
180
|
+
output.on('error', reject);
|
|
181
|
+
output.on('finish', () => {
|
|
182
|
+
execFile('tar', ['xf', 'bundle.tar'], { cwd: unpackDir }, err => {
|
|
183
|
+
if (err) reject(new Error(`unpack failed: ${err.message}`));
|
|
184
|
+
else resolve(unpackDir);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
input.pipe(require('zlib').createGunzip()).pipe(output);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The bundle must contain adm/app (with package.json) + adm/runtime —
|
|
192
|
+
// anything else aborts the swap BEFORE the old install is touched.
|
|
193
|
+
function verifyUnpackedLayout(unpackDir, fsMod = fs) {
|
|
194
|
+
const appPkg = path.join(unpackDir, 'adm', 'app', 'package.json');
|
|
195
|
+
const runtime = path.join(unpackDir, 'adm', 'runtime');
|
|
196
|
+
try {
|
|
197
|
+
fsMod.accessSync(appPkg);
|
|
198
|
+
fsMod.accessSync(runtime);
|
|
199
|
+
} catch {
|
|
200
|
+
throw new Error('bundle layout unexpected (adm/app + adm/runtime missing) — aborting');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── atomic swap with rollback ───────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
// rename with a cross-device fallback (tmp dirs may live on another mount —
|
|
207
|
+
// EXDEV): copy recursively, then remove the source.
|
|
208
|
+
function moveDir(src, dest, fsMod) {
|
|
209
|
+
try {
|
|
210
|
+
fsMod.renameSync(src, dest);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if (e && e.code === 'EXDEV') {
|
|
213
|
+
fsMod.cpSync(src, dest, { recursive: true });
|
|
214
|
+
fsMod.rmSync(src, { recursive: true, force: true });
|
|
215
|
+
} else {
|
|
216
|
+
throw e;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const BACKUP_APP = /^app\.bak-\d+$/;
|
|
222
|
+
const BACKUP_RUNTIME = /^runtime\.bak-\d+$/;
|
|
223
|
+
|
|
224
|
+
// Best-effort removal of leftovers from a previous upgrade (on Windows the
|
|
225
|
+
// running node.exe can keep a backup alive — the next upgrade clears it).
|
|
226
|
+
function cleanStaleBackups(admHome, fsMod) {
|
|
227
|
+
for (const entry of fsMod.readdirSync(admHome)) {
|
|
228
|
+
if (BACKUP_APP.test(entry) || BACKUP_RUNTIME.test(entry)) {
|
|
229
|
+
try { fsMod.rmSync(path.join(admHome, entry), { recursive: true, force: true }); } catch { /* keep */ }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Swaps the unpacked bundle into admHome:
|
|
235
|
+
// 1. rename old app → app.bak-<stamp>, old runtime → runtime.bak-<stamp>
|
|
236
|
+
// (only the parts that exist)
|
|
237
|
+
// 2. move the new app/runtime in
|
|
238
|
+
// 3. any failure → remove partial new dirs, rename backups back (rollback)
|
|
239
|
+
// 4. success → remove backups (best-effort; a Windows backup pinned by the
|
|
240
|
+
// running runtime is reported back as keptBackups)
|
|
241
|
+
function swapBundle({ admHome, unpackDir, fsMod = fs, stamp }) {
|
|
242
|
+
const newApp = path.join(unpackDir, 'adm', 'app');
|
|
243
|
+
const newRuntime = path.join(unpackDir, 'adm', 'runtime');
|
|
244
|
+
const backups = []; // { from, to, exists }
|
|
245
|
+
const done = []; // destinations already populated — removed on rollback
|
|
246
|
+
|
|
247
|
+
const backup = name => {
|
|
248
|
+
const from = path.join(admHome, name);
|
|
249
|
+
try {
|
|
250
|
+
fsMod.accessSync(from);
|
|
251
|
+
} catch {
|
|
252
|
+
return; // nothing to back up (first install state)
|
|
253
|
+
}
|
|
254
|
+
const to = path.join(admHome, `${name}.bak-${stamp}`);
|
|
255
|
+
fsMod.renameSync(from, to);
|
|
256
|
+
backups.push({ from, to });
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
cleanStaleBackups(admHome, fsMod);
|
|
261
|
+
backup('app');
|
|
262
|
+
backup('runtime');
|
|
263
|
+
fsMod.mkdirSync(admHome, { recursive: true });
|
|
264
|
+
moveDir(newApp, path.join(admHome, 'app'), fsMod);
|
|
265
|
+
done.push(path.join(admHome, 'app'));
|
|
266
|
+
moveDir(newRuntime, path.join(admHome, 'runtime'), fsMod);
|
|
267
|
+
done.push(path.join(admHome, 'runtime'));
|
|
268
|
+
} catch (e) {
|
|
269
|
+
// ── rollback: old install must come back intact (AC) ─────────────
|
|
270
|
+
for (const d of done) {
|
|
271
|
+
try { fsMod.rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
272
|
+
}
|
|
273
|
+
for (const b of backups) {
|
|
274
|
+
try { fsMod.renameSync(b.to, b.from); } catch { /* reported below */ }
|
|
275
|
+
}
|
|
276
|
+
const err = new Error(`swap failed: ${e.message} — the previous version was restored`);
|
|
277
|
+
err.rolledBack = true;
|
|
278
|
+
throw err;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Success: drop the backups. On Windows the running runtime can pin the
|
|
282
|
+
// backup — keep it and say so.
|
|
283
|
+
const keptBackups = [];
|
|
284
|
+
for (const b of backups) {
|
|
285
|
+
try {
|
|
286
|
+
fsMod.rmSync(b.to, { recursive: true, force: true });
|
|
287
|
+
} catch {
|
|
288
|
+
keptBackups.push(b.to);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { keptBackups };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── the command ─────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
function fmtBytes(n) {
|
|
297
|
+
if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
298
|
+
return `${Math.max(1, Math.round(n / 1024))} kB`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function runUpgrade(argv = [], deps = {}) {
|
|
302
|
+
const baseLog = deps.log || (s => process.stdout.write(s + '\n'));
|
|
303
|
+
const baseErr = deps.err || (s => process.stderr.write(s + '\n'));
|
|
304
|
+
// tee: everything the upgrade prints is collected for the persisted report
|
|
305
|
+
const output = [];
|
|
306
|
+
const log = s => { output.push(s); baseLog(s); };
|
|
307
|
+
const err = s => { output.push(s); baseErr(s); };
|
|
308
|
+
const fsMod = deps.fs || fs;
|
|
309
|
+
const env = deps.env || process.env;
|
|
310
|
+
const platform = deps.platform || process.platform;
|
|
311
|
+
const home = deps.home || os.homedir();
|
|
312
|
+
const fetchFn = deps.fetchFn || global.fetch;
|
|
313
|
+
const spinnerFactory = deps.spinnerFactory || (t => loader(t));
|
|
314
|
+
const currentVersion = deps.version || getVersion();
|
|
315
|
+
const apiBase = deps.apiBase || API_BASE;
|
|
316
|
+
const downloadBase = deps.downloadBase || DOWNLOAD_BASE;
|
|
317
|
+
|
|
318
|
+
// Persist the upgrade report to the global cache (owner decision).
|
|
319
|
+
// Only REAL upgrade attempts are persisted — `--check`, npm-channel hints
|
|
320
|
+
// and `--dry-run` are status queries, not reports.
|
|
321
|
+
const persistReport = status => {
|
|
322
|
+
if (!deps.configDir) return; // explicit config dir required from callers/tests
|
|
323
|
+
const body = [
|
|
324
|
+
`ADM upgrade report — ${new Date().toISOString()}`,
|
|
325
|
+
`ADM ${currentVersion} → v${latest || '?'} · ${status}`,
|
|
326
|
+
'',
|
|
327
|
+
output.join('\n'),
|
|
328
|
+
].join('\n') + '\n';
|
|
329
|
+
const saved = saveReport('upgrade', body, { configDir: deps.configDir });
|
|
330
|
+
if (saved.error) baseErr(`⚠ ${saved.error}`);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// ── flags ────────────────────────────────────────────────────────────
|
|
334
|
+
const check = argv.includes('--check');
|
|
335
|
+
const dryRun = argv.includes('--dry-run');
|
|
336
|
+
const bad = argv.filter(f => f !== '--check' && f !== '--dry-run');
|
|
337
|
+
if (bad.length > 0) {
|
|
338
|
+
err([
|
|
339
|
+
`Unknown flag for "upgrade": ${bad.join(', ')}`,
|
|
340
|
+
'',
|
|
341
|
+
'Valid flags:',
|
|
342
|
+
' --check only check whether a newer version exists (no changes)',
|
|
343
|
+
' --dry-run print the upgrade plan without changing anything',
|
|
344
|
+
].join('\n'));
|
|
345
|
+
return 1;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── channel ──────────────────────────────────────────────────────────
|
|
349
|
+
const { channel, admHome } = detectChannel({ env, home, platform, fs: fsMod, admHome: deps.admHome });
|
|
350
|
+
if (channel === 'npm') {
|
|
351
|
+
log('ADM was installed via npm — npm handles the update.');
|
|
352
|
+
log('');
|
|
353
|
+
log(` npm update -g ${NPM_PACKAGE}`);
|
|
354
|
+
log('');
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── resolve the latest release (short spinner) ───────────────────────
|
|
359
|
+
let latest;
|
|
360
|
+
let spin = spinnerFactory('Checking the latest release...');
|
|
361
|
+
spin.start();
|
|
362
|
+
try {
|
|
363
|
+
latest = await fetchLatestVersion({ apiBase, fetchFn });
|
|
364
|
+
} catch (e) {
|
|
365
|
+
spin.fail('Could not resolve the latest release');
|
|
366
|
+
err(`ADM failed: ${e.message}`);
|
|
367
|
+
persistReport('failed (cannot resolve latest release)');
|
|
368
|
+
return 2;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const cmp = compareVersions(latest, currentVersion);
|
|
372
|
+
if (cmp <= 0) {
|
|
373
|
+
spin.info(cmp === 0
|
|
374
|
+
? `ADM is up to date (${currentVersion}).`
|
|
375
|
+
: `ADM ${currentVersion} is newer than the latest release (v${latest}) — nothing to do.`);
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let target;
|
|
380
|
+
try {
|
|
381
|
+
target = targetFor(platform, deps.arch || process.arch);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
spin.fail('No bundle for this platform');
|
|
384
|
+
err(`ADM failed: ${e.message}`);
|
|
385
|
+
return 2;
|
|
386
|
+
}
|
|
387
|
+
const asset = assetName(latest, target);
|
|
388
|
+
const url = `${downloadBase}/v${latest}/${asset}`;
|
|
389
|
+
const shaUrl = `${url}.sha256`;
|
|
390
|
+
|
|
391
|
+
// ── --check: read-only answer, exit 0 both ways (AC) ─────────────────
|
|
392
|
+
if (check) {
|
|
393
|
+
spin.succeed(`A newer version is available: v${latest} (installed: ${currentVersion}).`);
|
|
394
|
+
log('');
|
|
395
|
+
log('Run: adm upgrade');
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── --dry-run: the plan (asset, url, sha256, target), no changes (AC) ─
|
|
400
|
+
if (dryRun) {
|
|
401
|
+
let sha = null;
|
|
402
|
+
try {
|
|
403
|
+
const tmp = fsMod.mkdtempSync(path.join(require('os').tmpdir(), 'adm-upgrade-'));
|
|
404
|
+
const shaFile = path.join(tmp, `${asset}.sha256`);
|
|
405
|
+
await downloadFile(shaUrl, shaFile, { fetchFn, timeoutMs: 30 * 1000 });
|
|
406
|
+
sha = parseSha256(fsMod.readFileSync(shaFile, 'utf8'));
|
|
407
|
+
fsMod.rmSync(tmp, { recursive: true, force: true });
|
|
408
|
+
} catch { /* sha optional in the plan */ }
|
|
409
|
+
spin.succeed('Upgrade plan (dry run — nothing was changed):');
|
|
410
|
+
log('');
|
|
411
|
+
log(` version: ${latest} (installed: ${currentVersion})`);
|
|
412
|
+
log(` asset: ${asset}`);
|
|
413
|
+
log(` url: ${url}`);
|
|
414
|
+
log(` sha256: ${sha || '(unavailable)'}`);
|
|
415
|
+
log(` target: ${admHome} (app + runtime swap)`);
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── real upgrade: download → verify → swap (rollback on failure) ─────
|
|
420
|
+
const tmp = fsMod.mkdtempSync(path.join(require('os').tmpdir(), 'adm-upgrade-'));
|
|
421
|
+
try {
|
|
422
|
+
const bundlePath = path.join(tmp, asset);
|
|
423
|
+
let lastPct = -1;
|
|
424
|
+
await downloadFile(url, bundlePath, {
|
|
425
|
+
fetchFn,
|
|
426
|
+
onProgress: ({ received, total }) => {
|
|
427
|
+
if (!total) {
|
|
428
|
+
spin.text(`Downloading ${asset} (${fmtBytes(received)})`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const pct = Math.floor((received / total) * 100);
|
|
432
|
+
if (pct !== lastPct) {
|
|
433
|
+
lastPct = pct;
|
|
434
|
+
spin.text(`Downloading ${asset} (${pct}%)`);
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// sha256 BEFORE anything is written to the install (AC: bad checksum →
|
|
440
|
+
// abort, old version intact).
|
|
441
|
+
spin.text('Verifying sha256...');
|
|
442
|
+
const shaFile = path.join(tmp, `${asset}.sha256`);
|
|
443
|
+
await downloadFile(shaUrl, shaFile, { fetchFn, timeoutMs: 30 * 1000 });
|
|
444
|
+
const expected = parseSha256(fsMod.readFileSync(shaFile, 'utf8'));
|
|
445
|
+
const actual = await sha256File(bundlePath, fsMod);
|
|
446
|
+
if (!expected || expected !== actual) {
|
|
447
|
+
spin.fail('Checksum mismatch');
|
|
448
|
+
err([
|
|
449
|
+
`ADM failed: sha256 mismatch for ${asset} — the bundle is corrupted or tampered with.`,
|
|
450
|
+
'Nothing was changed; your current install is intact.',
|
|
451
|
+
` expected: ${expected || '(missing checksum file)'}`,
|
|
452
|
+
` actual: ${actual}`,
|
|
453
|
+
].join('\n'));
|
|
454
|
+
return 2;
|
|
455
|
+
}
|
|
456
|
+
spin.succeed('Checksum verified');
|
|
457
|
+
|
|
458
|
+
spin.text('Unpacking...');
|
|
459
|
+
await unpackBundle(bundlePath, tmp, { execFileFn: deps.execFileFn });
|
|
460
|
+
verifyUnpackedLayout(tmp, fsMod);
|
|
461
|
+
|
|
462
|
+
spin.text('Swapping in the new version...');
|
|
463
|
+
const { keptBackups } = swapBundle({ admHome, unpackDir: tmp, fsMod, stamp: deps.stamp || Date.now() });
|
|
464
|
+
|
|
465
|
+
spin.succeed(`ADM updated to v${latest} — run \`adm -v\` to confirm.`);
|
|
466
|
+
if (keptBackups.length > 0) {
|
|
467
|
+
log(` note: the previous runtime was still running — its backup stays at ${keptBackups.join(', ')}`);
|
|
468
|
+
log(' (it is removed automatically by the next upgrade, or delete it manually)');
|
|
469
|
+
}
|
|
470
|
+
persistReport(`success (${currentVersion} → ${latest})`);
|
|
471
|
+
return 0;
|
|
472
|
+
} catch (e) {
|
|
473
|
+
if (spin) spin.fail(e.rolledBack ? 'Upgrade failed — your previous version was restored' : 'Upgrade failed');
|
|
474
|
+
err(`ADM failed: ${e.message}`);
|
|
475
|
+
persistReport(e.rolledBack ? 'failed (rolled back)' : 'failed');
|
|
476
|
+
return 2;
|
|
477
|
+
} finally {
|
|
478
|
+
try { fsMod.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
module.exports = {
|
|
483
|
+
runUpgrade,
|
|
484
|
+
detectChannel,
|
|
485
|
+
admHomeDir,
|
|
486
|
+
compareVersions,
|
|
487
|
+
fetchLatestVersion,
|
|
488
|
+
targetFor,
|
|
489
|
+
assetName,
|
|
490
|
+
downloadFile,
|
|
491
|
+
sha256File,
|
|
492
|
+
parseSha256,
|
|
493
|
+
unpackBundle,
|
|
494
|
+
verifyUnpackedLayout,
|
|
495
|
+
swapBundle,
|
|
496
|
+
moveDir,
|
|
497
|
+
REPO,
|
|
498
|
+
NPM_PACKAGE,
|
|
499
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Levenshtein edit distance, small strings only.
|
|
2
|
+
function editDistance(a, b) {
|
|
3
|
+
const m = a.length;
|
|
4
|
+
const n = b.length;
|
|
5
|
+
if (m === 0) return n;
|
|
6
|
+
if (n === 0) return m;
|
|
7
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
|
8
|
+
for (let i = 1; i <= m; i++) {
|
|
9
|
+
const cur = [i];
|
|
10
|
+
for (let j = 1; j <= n; j++) {
|
|
11
|
+
cur[j] = Math.min(
|
|
12
|
+
prev[j] + 1,
|
|
13
|
+
cur[j - 1] + 1,
|
|
14
|
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
prev = cur;
|
|
18
|
+
}
|
|
19
|
+
return prev[n];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Returns candidates within a small edit distance, closest first.
|
|
23
|
+
function closestMatches(input, candidates, maxDistance = 2, limit = 3) {
|
|
24
|
+
const scored = [];
|
|
25
|
+
for (const candidate of candidates) {
|
|
26
|
+
const d = editDistance(input.toLowerCase(), candidate.toLowerCase());
|
|
27
|
+
if (d > 0 && d <= maxDistance) scored.push({ candidate, d });
|
|
28
|
+
if (candidate.toLowerCase().startsWith(input.toLowerCase()) && d > maxDistance) {
|
|
29
|
+
scored.push({ candidate, d });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
scored.sort((x, y) => x.d - y.d);
|
|
33
|
+
return scored.slice(0, limit).map(s => s.candidate);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { editDistance, closestMatches };
|