@moneysiren/cli 0.1.0-alpha.0 → 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/apps/cli/src/cli.d.ts +1 -1
- package/dist/apps/cli/src/cli.js +2 -1
- package/dist/apps/cli/src/commands/install.js +125 -6
- package/dist/apps/cli/src/commands/modes.js +7 -4
- package/dist/apps/cli/src/index.js +0 -0
- package/dist/apps/cli/src/release-installer.d.ts +54 -0
- package/dist/apps/cli/src/release-installer.js +393 -0
- package/dist/apps/cli/src/version.d.ts +2 -0
- package/dist/apps/cli/src/version.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ moneysiren
|
|
|
20
20
|
moneysiren --version
|
|
21
21
|
moneysiren /version
|
|
22
22
|
moneysiren install --status
|
|
23
|
+
moneysiren install --all
|
|
23
24
|
moneysiren modes
|
|
24
25
|
moneysiren /modes
|
|
25
26
|
moneysiren doctor
|
|
@@ -33,7 +34,7 @@ During a PowerShell, cmd, or shell install with an interactive TTY, `postinstall
|
|
|
33
34
|
- Web dashboard
|
|
34
35
|
- HUD
|
|
35
36
|
|
|
36
|
-
Press Enter to accept the recommended default, which selects all three. In CI or non-interactive npm installs, MoneySiren writes that all-selected profile automatically.
|
|
37
|
+
Press Enter to accept the recommended default, which selects all three. In CI or non-interactive npm installs, MoneySiren writes that all-selected profile automatically. Run `moneysiren install --all` to download GitHub Release assets for the web runtime and HUD desktop shell. Use `moneysiren install --profile-only` to change only the local profile, or `moneysiren install --status` to inspect it.
|
|
37
38
|
|
|
38
39
|
One-off execution:
|
|
39
40
|
|
|
@@ -66,7 +67,7 @@ Install the generated tarball into a temporary project:
|
|
|
66
67
|
mkdir -p /tmp/moneysiren-alpha-review
|
|
67
68
|
cd /tmp/moneysiren-alpha-review
|
|
68
69
|
npm init -y
|
|
69
|
-
npm install /path/to/moneysiren-cli-0.1.0-alpha.
|
|
70
|
+
npm install /path/to/moneysiren-cli-0.1.0-alpha.1.tgz
|
|
70
71
|
npm exec moneysiren
|
|
71
72
|
npm exec moneysiren -- --version
|
|
72
73
|
npm exec moneysiren -- /version
|
|
@@ -82,7 +83,7 @@ PowerShell equivalent for the temporary project:
|
|
|
82
83
|
New-Item -ItemType Directory -Force -Path $env:TEMP\moneysiren-alpha-review
|
|
83
84
|
Set-Location $env:TEMP\moneysiren-alpha-review
|
|
84
85
|
npm init -y
|
|
85
|
-
npm install C:\path\to\moneysiren-cli-0.1.0-alpha.
|
|
86
|
+
npm install C:\path\to\moneysiren-cli-0.1.0-alpha.1.tgz
|
|
86
87
|
npm exec moneysiren
|
|
87
88
|
npm exec moneysiren -- --version
|
|
88
89
|
npm exec moneysiren -- modes
|
|
@@ -104,6 +105,8 @@ npm run publish:cli:alpha
|
|
|
104
105
|
|
|
105
106
|
The dry run checks the full secret scan, package metadata, npm registry version availability, and tarball contents. The publish command requires `npm login` in the local terminal and publishes this package with the `alpha` tag and public access.
|
|
106
107
|
|
|
108
|
+
If npm requires passkey or browser approval, complete the URL printed by npm and rerun the publish command. For CI publishing, add a granular npm token with publish access and bypass 2FA enabled as the `NPM_TOKEN` GitHub repository secret, then run the `npm-publish-cli` workflow manually.
|
|
109
|
+
|
|
107
110
|
## Slash Home
|
|
108
111
|
|
|
109
112
|
Running `moneysiren` without subcommands prints a readable slash-command home guide. In a TTY it may enter a minimal line-based slash prompt; in CI or non-TTY package review it prints the guide and exits `0`.
|
|
@@ -5,7 +5,7 @@ import type { AwsCostExplorerClientAdapter } from "../../../packages/connectors/
|
|
|
5
5
|
import type { CloudflareBillingUsageClient } from "../../../packages/connectors/cloudflare/src/index.js";
|
|
6
6
|
import type { OpenAiUsageCostsClient } from "../../../packages/connectors/openai/src/index.js";
|
|
7
7
|
import type { SupabaseManagementClient } from "../../../packages/connectors/supabase/src/index.js";
|
|
8
|
-
export
|
|
8
|
+
export { CLI_VERSION } from "./version.js";
|
|
9
9
|
export interface CliRuntime {
|
|
10
10
|
cwd?: string;
|
|
11
11
|
env?: Record<string, string | undefined>;
|
package/dist/apps/cli/src/cli.js
CHANGED
|
@@ -14,7 +14,8 @@ import { runSlashPrompt } from "./interactive.js";
|
|
|
14
14
|
import { openUrlInBrowser } from "./runtime-adapter.js";
|
|
15
15
|
import { resolveSlashCommand } from "./slash.js";
|
|
16
16
|
import { createTheme } from "./theme.js";
|
|
17
|
-
|
|
17
|
+
import { CLI_VERSION } from "./version.js";
|
|
18
|
+
export { CLI_VERSION } from "./version.js";
|
|
18
19
|
const HELP = renderHelpScreen(CLI_VERSION);
|
|
19
20
|
export async function runCli(args, runtime = {}) {
|
|
20
21
|
const stdout = runtime.stdoutBuffer ?? [];
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { DEFAULT_INSTALL_SURFACES, INSTALL_SURFACES, isInstallSurface, readInstallProfileFile, resolveInstallProfilePath, writeInstallProfileFile, } from "../install-profile.js";
|
|
2
2
|
import { formatInstallSelectionLine, installSelectionHelp, parseInstallSurfaceSelection, promptForInstallSurfaces, } from "../install-selector.js";
|
|
3
|
+
import { DEFAULT_RELEASE_REPOSITORY, DEFAULT_RELEASE_TAG, installReleaseAssets, } from "../release-installer.js";
|
|
3
4
|
const INSTALL_USAGE = [
|
|
4
|
-
"Usage: moneysiren install [--status|--all|--cli|--web|--hud|--no-cli|--no-web|--no-hud]",
|
|
5
|
+
"Usage: moneysiren install [--status|--all|--cli|--web|--hud|--no-cli|--no-web|--no-hud] [--profile-only] [--tag <tag>] [--repo <owner/name>] [--dir <path>]",
|
|
5
6
|
"",
|
|
6
7
|
"Components:",
|
|
7
8
|
installSelectionHelp(),
|
|
8
9
|
"",
|
|
9
10
|
"Default: all components selected (recommended).",
|
|
11
|
+
`Release default: ${DEFAULT_RELEASE_REPOSITORY}@${DEFAULT_RELEASE_TAG}.`,
|
|
10
12
|
].join("\n");
|
|
11
13
|
export async function runInstallCommand(args, context) {
|
|
12
14
|
if (args.includes("--help") || args.includes("-h")) {
|
|
@@ -21,7 +23,15 @@ export async function runInstallCommand(args, context) {
|
|
|
21
23
|
context.stderr(INSTALL_USAGE);
|
|
22
24
|
return 1;
|
|
23
25
|
}
|
|
24
|
-
const selectedSurfaces = parsed ?? await selectedSurfacesFromPromptOrDefault(context);
|
|
26
|
+
const selectedSurfaces = parsed.selectedSurfaces ?? await selectedSurfacesFromPromptOrDefault(context);
|
|
27
|
+
const releaseResult = await installReleaseAssetsForSelectionSafely({
|
|
28
|
+
context,
|
|
29
|
+
parsed,
|
|
30
|
+
selectedSurfaces,
|
|
31
|
+
});
|
|
32
|
+
if (releaseResult === "failed") {
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
25
35
|
const profile = await writeInstallProfileFile({
|
|
26
36
|
selectedSurfaces,
|
|
27
37
|
source: "cli",
|
|
@@ -32,6 +42,7 @@ export async function runInstallCommand(args, context) {
|
|
|
32
42
|
});
|
|
33
43
|
context.stdout("MoneySiren install profile updated.");
|
|
34
44
|
context.stdout(formatInstallSelectionLine(profile.selectedSurfaces));
|
|
45
|
+
writeReleaseInstallResult(context, releaseResult);
|
|
35
46
|
context.stdout("Secrets returned: false");
|
|
36
47
|
return 0;
|
|
37
48
|
}
|
|
@@ -59,17 +70,63 @@ async function selectedSurfacesFromPromptOrDefault(context) {
|
|
|
59
70
|
}
|
|
60
71
|
function parseInstallArgs(args) {
|
|
61
72
|
if (args.length === 0) {
|
|
62
|
-
return
|
|
73
|
+
return {
|
|
74
|
+
profileOnly: false,
|
|
75
|
+
};
|
|
63
76
|
}
|
|
64
77
|
if (args.length === 1) {
|
|
65
78
|
const selected = parseInstallSurfaceSelection(args[0] ?? "");
|
|
66
79
|
if (selected !== null) {
|
|
67
|
-
return
|
|
80
|
+
return {
|
|
81
|
+
profileOnly: false,
|
|
82
|
+
selectedSurfaces: selected,
|
|
83
|
+
};
|
|
68
84
|
}
|
|
69
85
|
}
|
|
70
86
|
let explicitIncludes = false;
|
|
87
|
+
let installDir;
|
|
88
|
+
let profileOnly = false;
|
|
89
|
+
let releaseRepository;
|
|
90
|
+
let releaseTag;
|
|
71
91
|
const selected = new Set();
|
|
72
|
-
for (
|
|
92
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
93
|
+
const arg = args[index];
|
|
94
|
+
if (arg === undefined) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
if (arg === "--profile-only") {
|
|
98
|
+
profileOnly = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (arg === "--tag" || arg === "--repo" || arg === "--dir") {
|
|
102
|
+
const value = args[index + 1];
|
|
103
|
+
if (value === undefined || value.startsWith("--")) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (arg === "--tag") {
|
|
107
|
+
releaseTag = value;
|
|
108
|
+
}
|
|
109
|
+
else if (arg === "--repo") {
|
|
110
|
+
releaseRepository = value;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
installDir = value;
|
|
114
|
+
}
|
|
115
|
+
index += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (arg.startsWith("--tag=")) {
|
|
119
|
+
releaseTag = arg.slice("--tag=".length);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (arg.startsWith("--repo=")) {
|
|
123
|
+
releaseRepository = arg.slice("--repo=".length);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (arg.startsWith("--dir=")) {
|
|
127
|
+
installDir = arg.slice("--dir=".length);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
73
130
|
if (arg === "--all") {
|
|
74
131
|
for (const surface of DEFAULT_INSTALL_SURFACES) {
|
|
75
132
|
selected.add(surface);
|
|
@@ -107,10 +164,72 @@ function parseInstallArgs(args) {
|
|
|
107
164
|
explicitIncludes = true;
|
|
108
165
|
}
|
|
109
166
|
const normalized = INSTALL_SURFACES.filter((surface) => selected.has(surface));
|
|
110
|
-
|
|
167
|
+
if (normalized.length === 0 && explicitIncludes) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
...(installDir === undefined ? {} : { installDir }),
|
|
172
|
+
profileOnly,
|
|
173
|
+
...(releaseRepository === undefined ? {} : { releaseRepository }),
|
|
174
|
+
...(releaseTag === undefined ? {} : { releaseTag }),
|
|
175
|
+
...(normalized.length === 0 ? {} : { selectedSurfaces: normalized }),
|
|
176
|
+
};
|
|
111
177
|
}
|
|
112
178
|
function isDefaultSelection(selectedSurfaces) {
|
|
113
179
|
return selectedSurfaces.length === INSTALL_SURFACES.length &&
|
|
114
180
|
INSTALL_SURFACES.every((surface, index) => selectedSurfaces[index] === surface);
|
|
115
181
|
}
|
|
182
|
+
async function installReleaseAssetsForSelection(input) {
|
|
183
|
+
if (input.parsed.profileOnly) {
|
|
184
|
+
return "profile-only";
|
|
185
|
+
}
|
|
186
|
+
if (!input.selectedSurfaces.some((surface) => surface === "web" || surface === "hud")) {
|
|
187
|
+
return "cli-only";
|
|
188
|
+
}
|
|
189
|
+
return installReleaseAssets({
|
|
190
|
+
env: input.context.env,
|
|
191
|
+
fetchImpl: input.context.fetch,
|
|
192
|
+
...(input.parsed.installDir === undefined ? {} : { installDir: input.parsed.installDir }),
|
|
193
|
+
now: input.context.now,
|
|
194
|
+
...(input.parsed.releaseRepository === undefined ? {} : { repository: input.parsed.releaseRepository }),
|
|
195
|
+
selectedSurfaces: input.selectedSurfaces,
|
|
196
|
+
...(input.parsed.releaseTag === undefined ? {} : { tag: input.parsed.releaseTag }),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
async function installReleaseAssetsForSelectionSafely(input) {
|
|
200
|
+
try {
|
|
201
|
+
return await installReleaseAssetsForSelection(input);
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
205
|
+
input.context.stderr(`Release asset installation failed: ${message}`);
|
|
206
|
+
if (input.selectedSurfaces.includes("hud")) {
|
|
207
|
+
input.context.stderr("The selected HUD desktop artifact must be present, checksummed, and signed before MoneySiren will install it.");
|
|
208
|
+
input.context.stderr("For now, use `moneysiren install --web` to install only the web runtime, or retry after a signed desktop release is published.");
|
|
209
|
+
}
|
|
210
|
+
input.context.stderr("Install profile was not changed.");
|
|
211
|
+
return "failed";
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function writeReleaseInstallResult(context, result) {
|
|
215
|
+
if (result === "profile-only") {
|
|
216
|
+
context.stdout("Release assets: skipped (--profile-only).");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (result === "cli-only") {
|
|
220
|
+
context.stdout("Release assets: skipped (CLI-only selection).");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
writeReleaseInstallSummary(context, result);
|
|
224
|
+
}
|
|
225
|
+
function writeReleaseInstallSummary(context, result) {
|
|
226
|
+
context.stdout(`Release: ${result.repository}@${result.tag}`);
|
|
227
|
+
context.stdout(`Release URL: ${result.releaseUrl}`);
|
|
228
|
+
context.stdout(`Install directory: ${result.installDir}`);
|
|
229
|
+
for (const asset of result.assets) {
|
|
230
|
+
context.stdout(`Downloaded ${asset.surface}: ${asset.name}`);
|
|
231
|
+
context.stdout(` SHA256 verified: ${asset.checksumVerified ? "yes" : "checksum unavailable"}`);
|
|
232
|
+
context.stdout(` Signature verified: ${asset.signatureVerified ? "yes" : "not required"}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
116
235
|
//# sourceMappingURL=install.js.map
|
|
@@ -25,17 +25,20 @@ export async function runModesCommand(args, context) {
|
|
|
25
25
|
context.stdout(" Try: moneysiren sync --provider mock");
|
|
26
26
|
context.stdout("");
|
|
27
27
|
context.stdout("2. Local web dashboard/runtime");
|
|
28
|
-
context.stdout(` Status: ${surfaceStatus("web", selectedSurfaces)}
|
|
28
|
+
context.stdout(` Status: ${surfaceStatus("web", selectedSurfaces)} GitHub Release web runtime archive is installed by the CLI`);
|
|
29
|
+
context.stdout(" Install: moneysiren install --web");
|
|
29
30
|
context.stdout(" Try: moneysiren serve [--port <port>]");
|
|
30
31
|
context.stdout(" Try: moneysiren dashboard check");
|
|
31
|
-
context.stdout(" Note: the
|
|
32
|
+
context.stdout(" Note: the npm CLI starts the local API; full dashboard runtime ships as a release asset.");
|
|
32
33
|
context.stdout("");
|
|
33
34
|
context.stdout("3. Desktop tray/notifier");
|
|
34
|
-
context.stdout(` Status: ${surfaceStatus("hud", selectedSurfaces)} Windows/macOS target is the thin Tauri tray shell
|
|
35
|
+
context.stdout(` Status: ${surfaceStatus("hud", selectedSurfaces)} Windows/macOS target is the thin Tauri tray shell from GitHub Releases`);
|
|
36
|
+
context.stdout(" Install: moneysiren install --hud");
|
|
35
37
|
context.stdout(" Try: moneysiren desktop status");
|
|
36
38
|
context.stdout(" Try: moneysiren notify once --dry-run");
|
|
37
39
|
context.stdout("");
|
|
38
|
-
context.stdout("
|
|
40
|
+
context.stdout("Install recommended set: moneysiren install --all");
|
|
41
|
+
context.stdout("Change selection only: moneysiren install --profile-only");
|
|
39
42
|
return 0;
|
|
40
43
|
}
|
|
41
44
|
function surfaceStatus(surface, selectedSurfaces) {
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { InstallSurface } from "./install-profile.js";
|
|
2
|
+
export declare const DEFAULT_RELEASE_REPOSITORY = "ztwz11/moneysiren";
|
|
3
|
+
export declare const DEFAULT_RELEASE_TAG = "v0.1.0-alpha.0";
|
|
4
|
+
export interface ReleaseInstallOptions {
|
|
5
|
+
env?: Record<string, string | undefined>;
|
|
6
|
+
fetchImpl: typeof fetch;
|
|
7
|
+
installDir?: string;
|
|
8
|
+
now?: () => Date;
|
|
9
|
+
platform?: NodeJS.Platform;
|
|
10
|
+
repository?: string;
|
|
11
|
+
selectedSurfaces: readonly InstallSurface[];
|
|
12
|
+
signatureVerifier?: ReleaseAssetSignatureVerifier;
|
|
13
|
+
tag?: string;
|
|
14
|
+
trustedWindowsSignerThumbprints?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
export interface ReleaseInstallResult {
|
|
17
|
+
repository: string;
|
|
18
|
+
tag: string;
|
|
19
|
+
installDir: string;
|
|
20
|
+
releaseUrl: string;
|
|
21
|
+
assets: readonly InstalledReleaseAsset[];
|
|
22
|
+
}
|
|
23
|
+
export interface InstalledReleaseAsset {
|
|
24
|
+
surface: Exclude<InstallSurface, "cli">;
|
|
25
|
+
name: string;
|
|
26
|
+
path: string;
|
|
27
|
+
size: number;
|
|
28
|
+
sha256: string;
|
|
29
|
+
checksumVerified: boolean;
|
|
30
|
+
signatureVerified: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface ReleaseAssetSignatureVerifier {
|
|
33
|
+
verify(input: ReleaseAssetSignatureVerificationInput): Promise<ReleaseAssetSignatureVerificationResult>;
|
|
34
|
+
}
|
|
35
|
+
export interface ReleaseAssetSignatureVerificationInput {
|
|
36
|
+
assetName: string;
|
|
37
|
+
expectedSignerThumbprints?: readonly string[];
|
|
38
|
+
path: string;
|
|
39
|
+
platform: NodeJS.Platform;
|
|
40
|
+
surface: Exclude<InstallSurface, "cli">;
|
|
41
|
+
}
|
|
42
|
+
export interface ReleaseAssetSignatureVerificationResult {
|
|
43
|
+
verified: boolean;
|
|
44
|
+
status: string;
|
|
45
|
+
message: string;
|
|
46
|
+
}
|
|
47
|
+
export declare function installReleaseAssets(options: ReleaseInstallOptions): Promise<ReleaseInstallResult>;
|
|
48
|
+
export declare function resolveReleaseInstallDir(input?: {
|
|
49
|
+
env?: Record<string, string | undefined>;
|
|
50
|
+
installDir?: string;
|
|
51
|
+
platform?: NodeJS.Platform;
|
|
52
|
+
tag?: string;
|
|
53
|
+
}): string;
|
|
54
|
+
//# sourceMappingURL=release-installer.d.ts.map
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, isAbsolute, join, posix, resolve, win32 } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
export const DEFAULT_RELEASE_REPOSITORY = "ztwz11/moneysiren";
|
|
9
|
+
// The npm CLI can patch faster than desktop assets; keep this pinned until a signed release supersedes it.
|
|
10
|
+
export const DEFAULT_RELEASE_TAG = "v0.1.0-alpha.0";
|
|
11
|
+
const RELEASE_REPOSITORY_ENV_KEY = "MONEYSIREN_RELEASE_REPOSITORY";
|
|
12
|
+
const RELEASE_TAG_ENV_KEY = "MONEYSIREN_RELEASE_TAG";
|
|
13
|
+
const RELEASE_INSTALL_DIR_ENV_KEY = "MONEYSIREN_RELEASE_INSTALL_DIR";
|
|
14
|
+
const RELEASE_PLATFORM_ENV_KEY = "MONEYSIREN_RELEASE_PLATFORM";
|
|
15
|
+
const WINDOWS_SIGNER_THUMBPRINTS_ENV_KEY = "MONEYSIREN_WINDOWS_SIGNER_THUMBPRINTS";
|
|
16
|
+
export async function installReleaseAssets(options) {
|
|
17
|
+
const env = options.env ?? process.env;
|
|
18
|
+
const repository = normalizeRepository(options.repository ?? env[RELEASE_REPOSITORY_ENV_KEY] ?? DEFAULT_RELEASE_REPOSITORY);
|
|
19
|
+
const tag = normalizeTag(options.tag ?? env[RELEASE_TAG_ENV_KEY] ?? DEFAULT_RELEASE_TAG);
|
|
20
|
+
const platform = normalizePlatform(options.platform ?? env[RELEASE_PLATFORM_ENV_KEY] ?? process.platform);
|
|
21
|
+
const configuredInstallDir = options.installDir ?? env[RELEASE_INSTALL_DIR_ENV_KEY];
|
|
22
|
+
const installDir = resolveReleaseInstallDir({
|
|
23
|
+
env,
|
|
24
|
+
...(configuredInstallDir === undefined ? {} : { installDir: configuredInstallDir }),
|
|
25
|
+
platform,
|
|
26
|
+
tag,
|
|
27
|
+
});
|
|
28
|
+
const release = await fetchRelease({
|
|
29
|
+
fetchImpl: options.fetchImpl,
|
|
30
|
+
repository,
|
|
31
|
+
tag,
|
|
32
|
+
});
|
|
33
|
+
const releaseAssets = parseReleaseAssets(release.assets);
|
|
34
|
+
const checksumAssets = releaseAssets.filter((asset) => asset.name.toLowerCase().includes("sha256sums"));
|
|
35
|
+
const requestedSurfaces = options.selectedSurfaces.filter((surface) => surface === "web" || surface === "hud");
|
|
36
|
+
const installedAssets = [];
|
|
37
|
+
await mkdir(installDir, { recursive: true });
|
|
38
|
+
for (const surface of requestedSurfaces) {
|
|
39
|
+
const asset = selectSurfaceAsset(surface, platform, releaseAssets);
|
|
40
|
+
if (asset === null) {
|
|
41
|
+
throw new Error(`No ${surface} release asset found for ${platform} in ${repository}@${tag}.`);
|
|
42
|
+
}
|
|
43
|
+
const downloaded = await downloadAsset(options.fetchImpl, asset.browser_download_url);
|
|
44
|
+
const sha256 = sha256Hex(downloaded);
|
|
45
|
+
const checksum = await findChecksum({
|
|
46
|
+
assetName: asset.name,
|
|
47
|
+
checksumAssets,
|
|
48
|
+
fetchImpl: options.fetchImpl,
|
|
49
|
+
});
|
|
50
|
+
if (checksumAssets.length > 0 && checksum === null) {
|
|
51
|
+
throw new Error(`SHA256 checksum entry missing for ${asset.name}.`);
|
|
52
|
+
}
|
|
53
|
+
if (checksum !== null && checksum.toLowerCase() !== sha256) {
|
|
54
|
+
throw new Error(`SHA256 mismatch for ${asset.name}.`);
|
|
55
|
+
}
|
|
56
|
+
const outputPath = join(installDir, sanitizeAssetFileName(asset.name));
|
|
57
|
+
await writeFile(outputPath, downloaded);
|
|
58
|
+
let signature;
|
|
59
|
+
try {
|
|
60
|
+
signature = await verifyReleaseAssetSignature({
|
|
61
|
+
assetName: asset.name,
|
|
62
|
+
env,
|
|
63
|
+
fetchImpl: options.fetchImpl,
|
|
64
|
+
path: outputPath,
|
|
65
|
+
platform,
|
|
66
|
+
releaseAssets,
|
|
67
|
+
surface,
|
|
68
|
+
...(options.signatureVerifier === undefined ? {} : { signatureVerifier: options.signatureVerifier }),
|
|
69
|
+
...(options.trustedWindowsSignerThumbprints === undefined
|
|
70
|
+
? {}
|
|
71
|
+
: { trustedWindowsSignerThumbprints: options.trustedWindowsSignerThumbprints }),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
await unlink(outputPath).catch(() => undefined);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
if (!signature.verified) {
|
|
79
|
+
await unlink(outputPath).catch(() => undefined);
|
|
80
|
+
throw new Error(`Release asset signature verification failed for ${asset.name}: ${signature.status} ${signature.message}`.trim());
|
|
81
|
+
}
|
|
82
|
+
installedAssets.push({
|
|
83
|
+
surface,
|
|
84
|
+
name: asset.name,
|
|
85
|
+
path: outputPath,
|
|
86
|
+
size: downloaded.byteLength,
|
|
87
|
+
sha256,
|
|
88
|
+
checksumVerified: checksum !== null,
|
|
89
|
+
signatureVerified: signature.status !== "not-required",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
await writeFile(join(installDir, "install-manifest.json"), `${JSON.stringify({
|
|
93
|
+
version: 1,
|
|
94
|
+
repository,
|
|
95
|
+
tag,
|
|
96
|
+
releaseUrl: typeof release.html_url === "string" ? release.html_url : releaseUrl(repository, tag),
|
|
97
|
+
installedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
98
|
+
selectedSurfaces: options.selectedSurfaces,
|
|
99
|
+
assets: installedAssets.map((asset) => ({
|
|
100
|
+
surface: asset.surface,
|
|
101
|
+
name: asset.name,
|
|
102
|
+
path: asset.path,
|
|
103
|
+
size: asset.size,
|
|
104
|
+
sha256: asset.sha256,
|
|
105
|
+
checksumVerified: asset.checksumVerified,
|
|
106
|
+
signatureVerified: asset.signatureVerified,
|
|
107
|
+
})),
|
|
108
|
+
}, null, 2)}\n`, "utf8");
|
|
109
|
+
return {
|
|
110
|
+
repository,
|
|
111
|
+
tag,
|
|
112
|
+
installDir,
|
|
113
|
+
releaseUrl: typeof release.html_url === "string" ? release.html_url : releaseUrl(repository, tag),
|
|
114
|
+
assets: installedAssets,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export function resolveReleaseInstallDir(input = {}) {
|
|
118
|
+
const env = input.env ?? process.env;
|
|
119
|
+
const platform = input.platform ?? process.platform;
|
|
120
|
+
const tag = input.tag ?? DEFAULT_RELEASE_TAG;
|
|
121
|
+
const configured = trimToNull(input.installDir);
|
|
122
|
+
if (configured !== null) {
|
|
123
|
+
return isAbsolute(configured) ? configured : resolve(process.cwd(), configured);
|
|
124
|
+
}
|
|
125
|
+
const root = platform === "win32"
|
|
126
|
+
? joinForPlatform(platform, trimToNull(env.APPDATA) ?? win32.join(resolveHomeDirectory(env), "AppData", "Roaming"), "MoneySiren")
|
|
127
|
+
: platform === "darwin"
|
|
128
|
+
? joinForPlatform(platform, resolveHomeDirectory(env), "Library", "Application Support", "MoneySiren")
|
|
129
|
+
: joinForPlatform(platform, trimToNull(env.XDG_DATA_HOME) ?? joinForPlatform(platform, resolveHomeDirectory(env), ".local", "share"), "moneysiren");
|
|
130
|
+
return joinForPlatform(platform, root, "releases", sanitizePathSegment(tag));
|
|
131
|
+
}
|
|
132
|
+
function normalizeRepository(repository) {
|
|
133
|
+
const normalized = repository.trim();
|
|
134
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalized)) {
|
|
135
|
+
throw new Error("Release repository must be in owner/name form.");
|
|
136
|
+
}
|
|
137
|
+
return normalized;
|
|
138
|
+
}
|
|
139
|
+
function normalizeTag(tag) {
|
|
140
|
+
const normalized = tag.trim();
|
|
141
|
+
if (normalized.length === 0 || normalized.length > 128) {
|
|
142
|
+
throw new Error("Release tag is empty or too long.");
|
|
143
|
+
}
|
|
144
|
+
return normalized;
|
|
145
|
+
}
|
|
146
|
+
function normalizePlatform(platform) {
|
|
147
|
+
if (platform === "win32" || platform === "darwin" || platform === "linux") {
|
|
148
|
+
return platform;
|
|
149
|
+
}
|
|
150
|
+
return process.platform;
|
|
151
|
+
}
|
|
152
|
+
async function fetchRelease(input) {
|
|
153
|
+
const response = await input.fetchImpl(`https://api.github.com/repos/${input.repository}/releases/tags/${encodeURIComponent(input.tag)}`, {
|
|
154
|
+
headers: {
|
|
155
|
+
Accept: "application/vnd.github+json",
|
|
156
|
+
"User-Agent": "moneysiren-cli-release-installer",
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
throw new Error(`Could not read GitHub Release ${input.repository}@${input.tag}: ${response.status} ${response.statusText}`);
|
|
161
|
+
}
|
|
162
|
+
const body = await response.json();
|
|
163
|
+
if (!isRecord(body)) {
|
|
164
|
+
throw new Error("GitHub Release response was not an object.");
|
|
165
|
+
}
|
|
166
|
+
return body;
|
|
167
|
+
}
|
|
168
|
+
function parseReleaseAssets(value) {
|
|
169
|
+
if (!Array.isArray(value)) {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
return value
|
|
173
|
+
.filter(isRecord)
|
|
174
|
+
.flatMap((asset) => {
|
|
175
|
+
const name = asset.name;
|
|
176
|
+
const browserDownloadUrl = asset.browser_download_url;
|
|
177
|
+
if (typeof name !== "string" || typeof browserDownloadUrl !== "string") {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
return [{
|
|
181
|
+
name,
|
|
182
|
+
browser_download_url: browserDownloadUrl,
|
|
183
|
+
...(typeof asset.size === "number" ? { size: asset.size } : {}),
|
|
184
|
+
}];
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function selectSurfaceAsset(surface, platform, assets) {
|
|
188
|
+
const candidates = assets.filter((asset) => !asset.name.toLowerCase().includes("sha256sums"));
|
|
189
|
+
if (surface === "web") {
|
|
190
|
+
return candidates.find((asset) => /^moneysiren-web-runtime-.+\.tar\.gz$/i.test(asset.name)) ?? null;
|
|
191
|
+
}
|
|
192
|
+
if (platform === "win32") {
|
|
193
|
+
return candidates.find((asset) => /\.(exe|msi)$/i.test(asset.name)) ?? null;
|
|
194
|
+
}
|
|
195
|
+
if (platform === "darwin") {
|
|
196
|
+
return candidates.find((asset) => /macos/i.test(asset.name) && /\.(tar\.gz|dmg)$/i.test(asset.name)) ?? null;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
async function findChecksum(input) {
|
|
201
|
+
for (const checksumAsset of input.checksumAssets) {
|
|
202
|
+
const content = await downloadAsset(input.fetchImpl, checksumAsset.browser_download_url);
|
|
203
|
+
const checksum = parseChecksumFile(content.toString("utf8"), input.assetName);
|
|
204
|
+
if (checksum !== null) {
|
|
205
|
+
return checksum;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
async function downloadAsset(fetchImpl, url) {
|
|
211
|
+
const parsed = new URL(url);
|
|
212
|
+
if (parsed.protocol !== "https:") {
|
|
213
|
+
throw new Error("Refusing to download a non-HTTPS release asset.");
|
|
214
|
+
}
|
|
215
|
+
const response = await fetchImpl(url, {
|
|
216
|
+
headers: {
|
|
217
|
+
"User-Agent": "moneysiren-cli-release-installer",
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
throw new Error(`Could not download release asset: ${response.status} ${response.statusText}`);
|
|
222
|
+
}
|
|
223
|
+
return Buffer.from(await response.arrayBuffer());
|
|
224
|
+
}
|
|
225
|
+
function parseChecksumFile(content, assetName) {
|
|
226
|
+
for (const line of content.split(/\r?\n/)) {
|
|
227
|
+
const match = /^([a-f0-9]{64})\s+\*?(.+)$/i.exec(line.trim());
|
|
228
|
+
if (match !== null && basename(match[2] ?? "") === assetName) {
|
|
229
|
+
return match[1] ?? null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
function sha256Hex(content) {
|
|
235
|
+
return createHash("sha256").update(content).digest("hex");
|
|
236
|
+
}
|
|
237
|
+
async function verifyReleaseAssetSignature(input) {
|
|
238
|
+
const verifier = input.signatureVerifier ?? defaultReleaseAssetSignatureVerifier;
|
|
239
|
+
const expectedSignerThumbprints = await findExpectedSignerThumbprints({
|
|
240
|
+
assetName: input.assetName,
|
|
241
|
+
env: input.env,
|
|
242
|
+
fetchImpl: input.fetchImpl,
|
|
243
|
+
platform: input.platform,
|
|
244
|
+
releaseAssets: input.releaseAssets,
|
|
245
|
+
surface: input.surface,
|
|
246
|
+
...(input.trustedWindowsSignerThumbprints === undefined
|
|
247
|
+
? {}
|
|
248
|
+
: { trustedWindowsSignerThumbprints: input.trustedWindowsSignerThumbprints }),
|
|
249
|
+
});
|
|
250
|
+
return verifier.verify({
|
|
251
|
+
assetName: input.assetName,
|
|
252
|
+
...(expectedSignerThumbprints === null ? {} : { expectedSignerThumbprints }),
|
|
253
|
+
path: input.path,
|
|
254
|
+
platform: input.platform,
|
|
255
|
+
surface: input.surface,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
const defaultReleaseAssetSignatureVerifier = {
|
|
259
|
+
async verify(input) {
|
|
260
|
+
if (input.surface !== "hud" || input.platform !== "win32") {
|
|
261
|
+
return {
|
|
262
|
+
verified: true,
|
|
263
|
+
status: "not-required",
|
|
264
|
+
message: "No platform signature check is required for this release asset.",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (!/\.(exe|msi)$/i.test(input.assetName)) {
|
|
268
|
+
return {
|
|
269
|
+
verified: false,
|
|
270
|
+
status: "unsupported",
|
|
271
|
+
message: "Windows HUD release assets must be .exe or .msi installers.",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (input.expectedSignerThumbprints === undefined || input.expectedSignerThumbprints.length === 0) {
|
|
275
|
+
return {
|
|
276
|
+
verified: false,
|
|
277
|
+
status: "missing-signature-metadata",
|
|
278
|
+
message: `Windows HUD release assets require ${WINDOWS_SIGNER_THUMBPRINTS_ENV_KEY} or moneysiren-tray-windows-SIGNATURE.json metadata.`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return verifyWindowsAuthenticodeSignature(input.path, input.expectedSignerThumbprints);
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
async function findExpectedSignerThumbprints(input) {
|
|
285
|
+
if (input.surface !== "hud" || input.platform !== "win32") {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const trustedThumbprints = normalizeThumbprintList([
|
|
289
|
+
...(input.trustedWindowsSignerThumbprints ?? []),
|
|
290
|
+
...parseThumbprintEnv(input.env[WINDOWS_SIGNER_THUMBPRINTS_ENV_KEY]),
|
|
291
|
+
]);
|
|
292
|
+
if (trustedThumbprints.length > 0) {
|
|
293
|
+
return trustedThumbprints;
|
|
294
|
+
}
|
|
295
|
+
const metadataAsset = input.releaseAssets.find((asset) => /^moneysiren-tray-windows-SIGNATURE\.json$/i.test(asset.name));
|
|
296
|
+
if (metadataAsset === undefined) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
const metadata = JSON.parse((await downloadAsset(input.fetchImpl, metadataAsset.browser_download_url)).toString("utf8"));
|
|
300
|
+
const entries = Array.isArray(metadata) ? metadata : [metadata];
|
|
301
|
+
for (const entry of entries) {
|
|
302
|
+
if (!isRecord(entry) || entry.assetName !== input.assetName || typeof entry.signerThumbprint !== "string") {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
return [normalizeThumbprint(entry.signerThumbprint)];
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
async function verifyWindowsAuthenticodeSignature(path, expectedSignerThumbprints) {
|
|
310
|
+
const literalPath = powerShellSingleQuotedString(path);
|
|
311
|
+
try {
|
|
312
|
+
const { stdout } = await execFileAsync("powershell.exe", [
|
|
313
|
+
"-NoProfile",
|
|
314
|
+
"-NonInteractive",
|
|
315
|
+
"-Command",
|
|
316
|
+
[
|
|
317
|
+
`$signature = Get-AuthenticodeSignature -LiteralPath ${literalPath}`,
|
|
318
|
+
"$status = [string]$signature.Status",
|
|
319
|
+
"$message = [string]$signature.StatusMessage",
|
|
320
|
+
"if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate) {",
|
|
321
|
+
" Write-Output ($status + \"|\" + $message)",
|
|
322
|
+
" exit 1",
|
|
323
|
+
"}",
|
|
324
|
+
"Write-Output ($status + \"|\" + $signature.SignerCertificate.Thumbprint + \"|\" + $signature.SignerCertificate.Subject)",
|
|
325
|
+
].join("; "),
|
|
326
|
+
], {
|
|
327
|
+
windowsHide: true,
|
|
328
|
+
timeout: 30_000,
|
|
329
|
+
});
|
|
330
|
+
const [status, signerThumbprint, ...messageParts] = stdout.trim().split("|");
|
|
331
|
+
const normalizedSignerThumbprint = normalizeThumbprint(signerThumbprint ?? "");
|
|
332
|
+
const normalizedExpectedSignerThumbprints = expectedSignerThumbprints.map(normalizeThumbprint);
|
|
333
|
+
if (!normalizedExpectedSignerThumbprints.includes(normalizedSignerThumbprint)) {
|
|
334
|
+
return {
|
|
335
|
+
verified: false,
|
|
336
|
+
status: "signer-mismatch",
|
|
337
|
+
message: `Expected signer ${normalizedExpectedSignerThumbprints.join(", ")}, got ${normalizedSignerThumbprint || "unknown"}.`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
verified: true,
|
|
342
|
+
status: status ?? "Valid",
|
|
343
|
+
message: messageParts.join("|"),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
const output = isRecord(error) && typeof error.stdout === "string" ? error.stdout.trim() : "";
|
|
348
|
+
const [status, ...messageParts] = output.split("|");
|
|
349
|
+
return {
|
|
350
|
+
verified: false,
|
|
351
|
+
status: status && status.length > 0 ? status : "Unknown",
|
|
352
|
+
message: messageParts.join("|") || (error instanceof Error ? error.message : String(error)),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function normalizeThumbprint(value) {
|
|
357
|
+
return value.replaceAll(/\s/g, "").toUpperCase();
|
|
358
|
+
}
|
|
359
|
+
function normalizeThumbprintList(values) {
|
|
360
|
+
return Array.from(new Set(values.map(normalizeThumbprint).filter((value) => value.length > 0)));
|
|
361
|
+
}
|
|
362
|
+
function parseThumbprintEnv(value) {
|
|
363
|
+
if (value === undefined) {
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
return value.split(/[,\s;]+/).map((part) => part.trim()).filter((part) => part.length > 0);
|
|
367
|
+
}
|
|
368
|
+
function powerShellSingleQuotedString(value) {
|
|
369
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
370
|
+
}
|
|
371
|
+
function sanitizeAssetFileName(name) {
|
|
372
|
+
return basename(name).replace(/[^A-Za-z0-9._ -]/g, "_");
|
|
373
|
+
}
|
|
374
|
+
function sanitizePathSegment(value) {
|
|
375
|
+
return value.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
376
|
+
}
|
|
377
|
+
function releaseUrl(repository, tag) {
|
|
378
|
+
return `https://github.com/${repository}/releases/tag/${encodeURIComponent(tag)}`;
|
|
379
|
+
}
|
|
380
|
+
function resolveHomeDirectory(env) {
|
|
381
|
+
return trimToNull(env.HOME) ?? trimToNull(env.USERPROFILE) ?? homedir();
|
|
382
|
+
}
|
|
383
|
+
function trimToNull(value) {
|
|
384
|
+
const trimmed = value?.trim();
|
|
385
|
+
return trimmed === undefined || trimmed.length === 0 ? null : trimmed;
|
|
386
|
+
}
|
|
387
|
+
function joinForPlatform(platform, ...segments) {
|
|
388
|
+
return platform === "win32" ? win32.join(...segments) : posix.join(...segments);
|
|
389
|
+
}
|
|
390
|
+
function isRecord(value) {
|
|
391
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
392
|
+
}
|
|
393
|
+
//# sourceMappingURL=release-installer.js.map
|