@appspacer/cli 1.0.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +271 -0
  3. package/dist/__tests__/api.test.d.ts +1 -0
  4. package/dist/__tests__/api.test.js +142 -0
  5. package/dist/__tests__/config.test.d.ts +1 -0
  6. package/dist/__tests__/config.test.js +109 -0
  7. package/dist/__tests__/hash.test.d.ts +1 -0
  8. package/dist/__tests__/hash.test.js +47 -0
  9. package/dist/__tests__/setup-injections.test.d.ts +1 -0
  10. package/dist/__tests__/setup-injections.test.js +238 -0
  11. package/dist/__tests__/zip.test.d.ts +1 -0
  12. package/dist/__tests__/zip.test.js +62 -0
  13. package/dist/api.d.ts +6 -0
  14. package/dist/api.js +52 -0
  15. package/dist/commands/deployments.d.ts +2 -0
  16. package/dist/commands/deployments.js +39 -0
  17. package/dist/commands/envsync.d.ts +2 -0
  18. package/dist/commands/envsync.js +230 -0
  19. package/dist/commands/login.d.ts +2 -0
  20. package/dist/commands/login.js +41 -0
  21. package/dist/commands/release-flutter.d.ts +2 -0
  22. package/dist/commands/release-flutter.js +176 -0
  23. package/dist/commands/release-react-native.d.ts +2 -0
  24. package/dist/commands/release-react-native.js +143 -0
  25. package/dist/commands/release.d.ts +2 -0
  26. package/dist/commands/release.js +106 -0
  27. package/dist/commands/rollback.d.ts +2 -0
  28. package/dist/commands/rollback.js +43 -0
  29. package/dist/commands/setup.d.ts +22 -0
  30. package/dist/commands/setup.js +575 -0
  31. package/dist/commands/vault.d.ts +2 -0
  32. package/dist/commands/vault.js +292 -0
  33. package/dist/commands/whoami.d.ts +2 -0
  34. package/dist/commands/whoami.js +16 -0
  35. package/dist/config.d.ts +8 -0
  36. package/dist/config.js +45 -0
  37. package/dist/index.d.ts +2 -0
  38. package/dist/index.js +25 -0
  39. package/dist/utils/bundle.d.ts +8 -0
  40. package/dist/utils/bundle.js +59 -0
  41. package/dist/utils/hash.d.ts +4 -0
  42. package/dist/utils/hash.js +9 -0
  43. package/dist/utils/ui.d.ts +19 -0
  44. package/dist/utils/ui.js +43 -0
  45. package/dist/utils/validators.d.ts +25 -0
  46. package/dist/utils/validators.js +65 -0
  47. package/dist/utils/zip.d.ts +5 -0
  48. package/dist/utils/zip.js +17 -0
  49. package/package.json +66 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AppSpacer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,271 @@
1
+ # AppSpacer CLI
2
+
3
+ **Professional-grade Over-The-Air (OTA) update CLI for React Native. Deploy JavaScript bundles and assets instantly — no app store review required.**
4
+
5
+ AppSpacer CLI lets you bundle, sign, and deploy over-the-air updates to your mobile apps in seconds. It also includes a built-in secrets vault for managing environment variables across teams.
6
+
7
+ Full documentation: [docs.appspacer.com](https://docs.appspacer.com)
8
+
9
+ ---
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install -g @appspacer/cli
15
+ ```
16
+
17
+ **Requirements:** Node.js 18 or higher.
18
+
19
+ ---
20
+
21
+ ## Quick Start
22
+
23
+ ```bash
24
+ # 1. Authenticate
25
+ appspacer login -t pat_your_token_here
26
+
27
+ # 2. Auto-configure your native project (React Native only)
28
+ appspacer setup
29
+
30
+ # 3. Push an OTA update
31
+ appspacer release-react-native android -a my-app -d Staging -t 1.0.0
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Commands
37
+
38
+ ### `appspacer login`
39
+
40
+ Authenticate with AppSpacer using a Personal Access Token (PAT).
41
+
42
+ ```bash
43
+ appspacer login -t pat_your_token_here
44
+
45
+ # Interactive — prompts for token if -t is omitted
46
+ appspacer login
47
+ ```
48
+
49
+ | Flag | Description |
50
+ |------|-------------|
51
+ | `-t, --token <token>` | Personal Access Token (must start with `pat_`) |
52
+ | `--api-url <url>` | Override the API base URL (for self-hosted deployments) |
53
+
54
+ Generate tokens at [appspacer.com/settings/tokens](https://appspacer.com/settings/tokens).
55
+
56
+ ---
57
+
58
+ ### `appspacer whoami`
59
+
60
+ Display the currently authenticated user.
61
+
62
+ ```bash
63
+ appspacer whoami
64
+ ```
65
+
66
+ ---
67
+
68
+ ### `appspacer setup`
69
+
70
+ Auto-configure native Android and iOS files to load OTA bundles from AppSpacer. Run this once after installing `react-native-appspacer`.
71
+
72
+ ```bash
73
+ # Configure both platforms
74
+ appspacer setup
75
+
76
+ # Android only
77
+ appspacer setup --android-only
78
+
79
+ # iOS only
80
+ appspacer setup --ios-only
81
+
82
+ # Preview changes without writing files
83
+ appspacer setup --dry-run
84
+
85
+ # Point to a project in a different directory
86
+ appspacer setup --project-dir ../my-app
87
+ ```
88
+
89
+ | Flag | Description |
90
+ |------|-------------|
91
+ | `--android-only` | Only configure Android |
92
+ | `--ios-only` | Only configure iOS |
93
+ | `--project-dir <dir>` | Root of the React Native project (default: `.`) |
94
+ | `--dry-run` | Preview injected code without modifying files |
95
+
96
+ The command auto-detects your React Native architecture (New Architecture / Traditional) and injects the correct bundle resolver into `MainApplication.kt` / `MainApplication.java` (Android) and `AppDelegate.mm` / `AppDelegate.swift` (iOS). Original files are backed up as `.appspacer.bak`.
97
+
98
+ ---
99
+
100
+ ### `appspacer setup:undo`
101
+
102
+ Remove all AppSpacer injections from native files.
103
+
104
+ ```bash
105
+ appspacer setup:undo
106
+
107
+ appspacer setup:undo --project-dir ../my-app
108
+ ```
109
+
110
+ ---
111
+
112
+ ### `appspacer release-react-native`
113
+
114
+ Bundle your React Native JavaScript and push it as an OTA update.
115
+
116
+ ```bash
117
+ # Android
118
+ appspacer release-react-native android -a my-app -d Staging -t 1.0.0
119
+
120
+ # iOS — mandatory update with a description
121
+ appspacer release-react-native ios -a my-app -d Production -t 2.1.0 --mandatory --description "Critical bug fix"
122
+
123
+ # Include assets (images, fonts) in the bundle
124
+ appspacer release-react-native android -a my-app -d Staging -t 1.0.0 --include-assets
125
+ ```
126
+
127
+ | Flag | Description |
128
+ |------|-------------|
129
+ | `<platform>` | `android` or `ios` (required positional argument) |
130
+ | `-a, --app <id>` | App ID or Name (required) |
131
+ | `-d, --deployment <name>` | Deployment name, e.g. `Staging`, `Production` (required) |
132
+ | `-t, --target-version <version>` | App store version this update targets, e.g. `1.0.0` (required) |
133
+ | `--description <text>` | Human-readable release notes |
134
+ | `--mandatory` | Mark update as mandatory (applied immediately on next launch) |
135
+ | `--include-assets` | Bundle assets along with JS (increases bundle size) |
136
+
137
+ ---
138
+
139
+ ### `appspacer release-flutter`
140
+
141
+ Package your Flutter `assets/` directory and push it as an OTA update. Run from your Flutter project root — the app version is read automatically from `pubspec.yaml`.
142
+
143
+ ```bash
144
+ # Android
145
+ appspacer release-flutter -p android -a my-app -d Staging
146
+
147
+ # iOS — mandatory
148
+ appspacer release-flutter -p ios -a my-app -d Production --mandatory
149
+
150
+ # With a description
151
+ appspacer release-flutter -p android -a my-app -d Staging --description "New onboarding assets"
152
+ ```
153
+
154
+ | Flag | Description |
155
+ |------|-------------|
156
+ | `-p, --platform <os>` | `android` or `ios` (required) |
157
+ | `-a, --app <id>` | App ID or Name (required) |
158
+ | `-d, --deployment <name>` | Deployment name (required) |
159
+ | `--description <text>` | Release notes |
160
+ | `--mandatory` | Mark update as mandatory |
161
+
162
+ ---
163
+
164
+ ### `appspacer release`
165
+
166
+ Upload a pre-built zip bundle manually (platform-agnostic).
167
+
168
+ ```bash
169
+ appspacer release -a my-app -d Staging -p android -t "1.x.x" -f ./bundle.zip
170
+ ```
171
+
172
+ | Flag | Description |
173
+ |------|-------------|
174
+ | `-a, --app <id>` | App ID or Name (required) |
175
+ | `-d, --deployment <name>` | Deployment name (required) |
176
+ | `-p, --platform <os>` | `android` or `ios` (required) |
177
+ | `-t, --target-version <version>` | Target app version (required) |
178
+ | `-f, --file <path>` | Path to the `.zip` bundle (required) |
179
+ | `--description <text>` | Release notes |
180
+ | `--mandatory` | Mark update as mandatory |
181
+
182
+ ---
183
+
184
+ ### `appspacer deployments`
185
+
186
+ List all releases in a deployment.
187
+
188
+ ```bash
189
+ appspacer deployments -a my-app
190
+ ```
191
+
192
+ ---
193
+
194
+ ### `appspacer rollback`
195
+
196
+ Disable the latest release and reactivate the previous one.
197
+
198
+ ```bash
199
+ appspacer rollback -a my-app -d Staging -p android
200
+
201
+ appspacer rollback -a my-app -d Production -p ios
202
+ ```
203
+
204
+ | Flag | Description |
205
+ |------|-------------|
206
+ | `-a, --app <id>` | App ID or Name (required) |
207
+ | `-d, --deployment <name>` | Deployment name (required) |
208
+ | `-p, --platform <os>` | `android` or `ios` (required) |
209
+
210
+ ---
211
+
212
+ ### `appspacer vault`
213
+
214
+ Manage and sync environment variables (secrets) via AppSpacer Vault.
215
+
216
+ ```bash
217
+ # Initialize vault in the current directory (interactive)
218
+ appspacer vault init
219
+
220
+ # Push local .env to the remote vault
221
+ appspacer vault push
222
+
223
+ # Pull secrets from the vault into a local .env file
224
+ appspacer vault pull
225
+
226
+ # Revert the most recent vault change
227
+ appspacer vault rollback
228
+
229
+ # View audit history
230
+ appspacer vault audit
231
+ ```
232
+
233
+ #### `vault env` — Environment management
234
+
235
+ ```bash
236
+ # List environments in the current project
237
+ appspacer vault env list
238
+
239
+ # Switch the active environment (and pull its secrets)
240
+ appspacer vault env use Production
241
+ ```
242
+
243
+ #### `vault secrets` — Individual secret management
244
+
245
+ ```bash
246
+ # List all remote secrets (values masked)
247
+ appspacer vault secrets ls
248
+
249
+ # Set a single secret
250
+ appspacer vault secrets set API_KEY=abc123
251
+ ```
252
+
253
+ ---
254
+
255
+ ## Configuration
256
+
257
+ AppSpacer CLI stores its configuration (token, API URL) in `~/.appspacer/config.json`. No manual editing is required.
258
+
259
+ ---
260
+
261
+ ## Links
262
+
263
+ - **Dashboard:** [appspacer.com](https://appspacer.com)
264
+ - **Documentation:** [docs.appspacer.com](https://docs.appspacer.com)
265
+ - **Issues / Support:** [appspacer.com/support](https://appspacer.com/support)
266
+
267
+ ---
268
+
269
+ ## License
270
+
271
+ MIT
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ vi.mock("../config.js", () => ({
3
+ getApiUrl: () => "https://api.test",
4
+ getToken: () => "pat_test_token",
5
+ }));
6
+ import { apiRequest } from "../api.js";
7
+ describe("apiRequest", () => {
8
+ const fetchMock = vi.fn();
9
+ beforeEach(() => {
10
+ vi.useFakeTimers();
11
+ vi.stubGlobal("fetch", fetchMock);
12
+ });
13
+ afterEach(() => {
14
+ vi.restoreAllMocks();
15
+ vi.useRealTimers();
16
+ });
17
+ // ── Happy path ───────────────────────────────────────────────────────────
18
+ it("makes a GET request to the correct URL with Authorization header", async () => {
19
+ fetchMock.mockResolvedValueOnce({
20
+ ok: true,
21
+ status: 200,
22
+ headers: new Headers(),
23
+ json: async () => ({ success: true, data: { id: "123" } }),
24
+ });
25
+ await apiRequest("/profile");
26
+ const [url, options] = fetchMock.mock.calls[0];
27
+ expect(url).toBe("https://api.test/profile");
28
+ expect(options.headers["Authorization"]).toBe("Bearer pat_test_token");
29
+ expect(options.headers["Content-Type"]).toBe("application/json");
30
+ });
31
+ it("returns the data field from the response body", async () => {
32
+ fetchMock.mockResolvedValueOnce({
33
+ ok: true,
34
+ status: 200,
35
+ headers: new Headers(),
36
+ json: async () => ({ success: true, data: { name: "Alice" } }),
37
+ });
38
+ const result = await apiRequest("/profile");
39
+ expect(result).toEqual({ name: "Alice" });
40
+ });
41
+ it("forwards custom request options (method, body)", async () => {
42
+ fetchMock.mockResolvedValueOnce({
43
+ ok: true,
44
+ status: 200,
45
+ headers: new Headers(),
46
+ json: async () => ({ success: true, data: {} }),
47
+ });
48
+ await apiRequest("/release", {
49
+ method: "POST",
50
+ body: JSON.stringify({ app_id: "abc" }),
51
+ });
52
+ const [, options] = fetchMock.mock.calls[0];
53
+ expect(options.method).toBe("POST");
54
+ expect(options.body).toBe(JSON.stringify({ app_id: "abc" }));
55
+ });
56
+ // ── Error handling ───────────────────────────────────────────────────────
57
+ it("throws when response.ok is false", async () => {
58
+ fetchMock.mockResolvedValueOnce({
59
+ ok: false,
60
+ status: 401,
61
+ headers: new Headers(),
62
+ json: async () => ({ success: false, error: { code: "UNAUTHORIZED", message: "Invalid token" } }),
63
+ });
64
+ await expect(apiRequest("/profile")).rejects.toThrow("Invalid token");
65
+ });
66
+ it("throws when success flag is false even with HTTP 200", async () => {
67
+ fetchMock.mockResolvedValueOnce({
68
+ ok: true,
69
+ status: 200,
70
+ headers: new Headers(),
71
+ json: async () => ({ success: false, error: { code: "NOT_FOUND", message: "App not found" } }),
72
+ });
73
+ await expect(apiRequest("/codepush/deployments?app_id=x")).rejects.toThrow("App not found");
74
+ });
75
+ it("throws a generic message when no error body is present", async () => {
76
+ fetchMock.mockResolvedValueOnce({
77
+ ok: false,
78
+ status: 500,
79
+ headers: new Headers(),
80
+ json: async () => ({ success: false }),
81
+ });
82
+ await expect(apiRequest("/release")).rejects.toThrow("Request failed with status 500");
83
+ });
84
+ // ── Timeout ──────────────────────────────────────────────────────────────
85
+ it("throws a timeout error when the request exceeds 30 seconds", async () => {
86
+ fetchMock.mockImplementationOnce((_url, opts) => {
87
+ // Simulate a request that never resolves, but respects abort
88
+ return new Promise((_resolve, reject) => {
89
+ opts.signal?.addEventListener("abort", () => {
90
+ reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" }));
91
+ });
92
+ });
93
+ });
94
+ const promise = apiRequest("/profile");
95
+ // Advance timers past the 30s timeout
96
+ vi.advanceTimersByTime(31_000);
97
+ await expect(promise).rejects.toThrow(/timed out/i);
98
+ });
99
+ // ── 429 retry ────────────────────────────────────────────────────────────
100
+ it("retries once on 429 and succeeds on the second attempt", async () => {
101
+ fetchMock
102
+ .mockResolvedValueOnce({
103
+ ok: false,
104
+ status: 429,
105
+ headers: new Headers({ "Retry-After": "1" }),
106
+ json: async () => ({ success: false }),
107
+ })
108
+ .mockResolvedValueOnce({
109
+ ok: true,
110
+ status: 200,
111
+ headers: new Headers(),
112
+ json: async () => ({ success: true, data: { id: "ok" } }),
113
+ });
114
+ const promise = apiRequest("/profile");
115
+ // Advance past the Retry-After: 1 second delay
116
+ await vi.advanceTimersByTimeAsync(2_000);
117
+ const result = await promise;
118
+ expect(result).toEqual({ id: "ok" });
119
+ expect(fetchMock).toHaveBeenCalledTimes(2);
120
+ });
121
+ it("uses the Retry-After header value as the delay before retrying", async () => {
122
+ fetchMock
123
+ .mockResolvedValueOnce({
124
+ ok: false,
125
+ status: 429,
126
+ headers: new Headers({ "Retry-After": "3" }),
127
+ json: async () => ({ success: false }),
128
+ })
129
+ .mockResolvedValueOnce({
130
+ ok: true,
131
+ status: 200,
132
+ headers: new Headers(),
133
+ json: async () => ({ success: true, data: { retried: true } }),
134
+ });
135
+ const promise = apiRequest("/profile");
136
+ // Flush microtasks then advance past the 3s Retry-After delay
137
+ await vi.advanceTimersByTimeAsync(4_000);
138
+ const result = await promise;
139
+ expect(result).toEqual({ retried: true });
140
+ expect(fetchMock).toHaveBeenCalledTimes(2);
141
+ });
142
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import path from "path";
3
+ // Inline the mock value in the factory — the factory is hoisted before const
4
+ // initializations run, so referencing outer-scope variables causes TDZ errors.
5
+ vi.mock("os", () => ({
6
+ default: { homedir: () => "/mock_home" },
7
+ }));
8
+ // Must match the value returned by the mocked os.homedir() above.
9
+ const MOCK_HOME = "/mock_home";
10
+ // In-memory filesystem shared across tests; mutated in-place so closures stay valid
11
+ const mockFiles = {};
12
+ vi.mock("fs", () => ({
13
+ default: {
14
+ existsSync: vi.fn((p) => p in mockFiles),
15
+ readFileSync: vi.fn((p) => {
16
+ if (!(p in mockFiles)) {
17
+ const e = new Error(`ENOENT: ${p}`);
18
+ e.code = "ENOENT";
19
+ throw e;
20
+ }
21
+ return mockFiles[p];
22
+ }),
23
+ writeFileSync: vi.fn((p, content) => {
24
+ mockFiles[p] = content;
25
+ }),
26
+ renameSync: vi.fn((from, to) => {
27
+ if (from in mockFiles) {
28
+ mockFiles[to] = mockFiles[from];
29
+ delete mockFiles[from];
30
+ }
31
+ }),
32
+ mkdirSync: vi.fn(),
33
+ },
34
+ }));
35
+ import { loadConfig, saveConfig, getToken, getApiUrl } from "../config.js";
36
+ // Mirror exactly how config.ts computes CONFIG_FILE so keys match on all platforms
37
+ const CONFIG_FILE = path.join(MOCK_HOME, ".appspacer", "config.json");
38
+ const DEFAULT_API_URL = "https://appspacer-middleware.onrender.com/api";
39
+ beforeEach(() => {
40
+ for (const key of Object.keys(mockFiles))
41
+ delete mockFiles[key];
42
+ });
43
+ // ─── loadConfig ──────────────────────────────────────────────────────────────
44
+ describe("loadConfig", () => {
45
+ it("returns default config when config file does not exist", () => {
46
+ const config = loadConfig();
47
+ expect(config.accessToken).toBeNull();
48
+ expect(config.apiUrl).toBe(DEFAULT_API_URL);
49
+ });
50
+ it("merges saved values over defaults", () => {
51
+ mockFiles[CONFIG_FILE] = JSON.stringify({ accessToken: "pat_abc123" });
52
+ const config = loadConfig();
53
+ expect(config.accessToken).toBe("pat_abc123");
54
+ expect(config.apiUrl).toBe(DEFAULT_API_URL);
55
+ });
56
+ it("returns default config when file contains invalid JSON", () => {
57
+ mockFiles[CONFIG_FILE] = "{ not valid json }}}";
58
+ const config = loadConfig();
59
+ expect(config.accessToken).toBeNull();
60
+ });
61
+ it("returns the full config when all fields are present", () => {
62
+ mockFiles[CONFIG_FILE] = JSON.stringify({
63
+ accessToken: "pat_xyz",
64
+ apiUrl: "https://custom.api",
65
+ });
66
+ const config = loadConfig();
67
+ expect(config.accessToken).toBe("pat_xyz");
68
+ expect(config.apiUrl).toBe("https://custom.api");
69
+ });
70
+ });
71
+ // ─── saveConfig ──────────────────────────────────────────────────────────────
72
+ describe("saveConfig", () => {
73
+ it("writes the config file as JSON", () => {
74
+ saveConfig({ accessToken: "pat_new" });
75
+ expect(mockFiles[CONFIG_FILE]).toBeDefined();
76
+ const saved = JSON.parse(mockFiles[CONFIG_FILE]);
77
+ expect(saved.accessToken).toBe("pat_new");
78
+ });
79
+ it("merges partial updates without overwriting other fields", () => {
80
+ mockFiles[CONFIG_FILE] = JSON.stringify({
81
+ accessToken: "pat_old",
82
+ apiUrl: "https://custom.api",
83
+ });
84
+ saveConfig({ accessToken: "pat_new" });
85
+ const saved = JSON.parse(mockFiles[CONFIG_FILE]);
86
+ expect(saved.apiUrl).toBe("https://custom.api");
87
+ expect(saved.accessToken).toBe("pat_new");
88
+ });
89
+ });
90
+ // ─── getToken ────────────────────────────────────────────────────────────────
91
+ describe("getToken", () => {
92
+ it("returns the token when one is saved", () => {
93
+ mockFiles[CONFIG_FILE] = JSON.stringify({ accessToken: "pat_secret" });
94
+ expect(getToken()).toBe("pat_secret");
95
+ });
96
+ it("throws when accessToken is null", () => {
97
+ expect(() => getToken()).toThrow(/Not logged in/);
98
+ });
99
+ });
100
+ // ─── getApiUrl ───────────────────────────────────────────────────────────────
101
+ describe("getApiUrl", () => {
102
+ it("returns the default API URL when no config saved", () => {
103
+ expect(getApiUrl()).toBe(DEFAULT_API_URL);
104
+ });
105
+ it("returns the custom API URL when one is saved", () => {
106
+ mockFiles[CONFIG_FILE] = JSON.stringify({ apiUrl: "https://my-server.com/api" });
107
+ expect(getApiUrl()).toBe("https://my-server.com/api");
108
+ });
109
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect, afterAll } from "vitest";
2
+ import crypto from "crypto";
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import path from "path";
6
+ import { computeFileHash } from "../utils/hash.js";
7
+ const TMP_DIR = path.join(os.tmpdir(), "appspacer-hash-tests");
8
+ describe("computeFileHash", () => {
9
+ afterAll(() => {
10
+ if (fs.existsSync(TMP_DIR))
11
+ fs.rmSync(TMP_DIR, { recursive: true, force: true });
12
+ });
13
+ function writeTmp(name, content) {
14
+ if (!fs.existsSync(TMP_DIR))
15
+ fs.mkdirSync(TMP_DIR, { recursive: true });
16
+ const p = path.join(TMP_DIR, name);
17
+ fs.writeFileSync(p, content);
18
+ return p;
19
+ }
20
+ it("returns a 64-char hex SHA-256 digest", () => {
21
+ const p = writeTmp("a.txt", "hello world");
22
+ const hash = computeFileHash(p);
23
+ expect(hash).toHaveLength(64);
24
+ expect(hash).toMatch(/^[0-9a-f]+$/);
25
+ });
26
+ it("returns the same hash for identical content", () => {
27
+ const p1 = writeTmp("b1.txt", "same content");
28
+ const p2 = writeTmp("b2.txt", "same content");
29
+ expect(computeFileHash(p1)).toBe(computeFileHash(p2));
30
+ });
31
+ it("returns different hashes for different content", () => {
32
+ const p1 = writeTmp("c1.txt", "content A");
33
+ const p2 = writeTmp("c2.txt", "content B");
34
+ expect(computeFileHash(p1)).not.toBe(computeFileHash(p2));
35
+ });
36
+ it("matches Node crypto SHA-256 for the same content", () => {
37
+ const data = "hello world";
38
+ const p = writeTmp("d.txt", data);
39
+ const expected = crypto.createHash("sha256").update(Buffer.from(data)).digest("hex");
40
+ expect(computeFileHash(p)).toBe(expected);
41
+ });
42
+ it("handles empty files without throwing", () => {
43
+ const p = writeTmp("empty.txt", "");
44
+ expect(() => computeFileHash(p)).not.toThrow();
45
+ expect(computeFileHash(p)).toHaveLength(64);
46
+ });
47
+ });
@@ -0,0 +1 @@
1
+ export {};