@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.
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/dist/__tests__/api.test.d.ts +1 -0
- package/dist/__tests__/api.test.js +142 -0
- package/dist/__tests__/config.test.d.ts +1 -0
- package/dist/__tests__/config.test.js +109 -0
- package/dist/__tests__/hash.test.d.ts +1 -0
- package/dist/__tests__/hash.test.js +47 -0
- package/dist/__tests__/setup-injections.test.d.ts +1 -0
- package/dist/__tests__/setup-injections.test.js +238 -0
- package/dist/__tests__/zip.test.d.ts +1 -0
- package/dist/__tests__/zip.test.js +62 -0
- package/dist/api.d.ts +6 -0
- package/dist/api.js +52 -0
- package/dist/commands/deployments.d.ts +2 -0
- package/dist/commands/deployments.js +39 -0
- package/dist/commands/envsync.d.ts +2 -0
- package/dist/commands/envsync.js +230 -0
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +41 -0
- package/dist/commands/release-flutter.d.ts +2 -0
- package/dist/commands/release-flutter.js +176 -0
- package/dist/commands/release-react-native.d.ts +2 -0
- package/dist/commands/release-react-native.js +143 -0
- package/dist/commands/release.d.ts +2 -0
- package/dist/commands/release.js +106 -0
- package/dist/commands/rollback.d.ts +2 -0
- package/dist/commands/rollback.js +43 -0
- package/dist/commands/setup.d.ts +22 -0
- package/dist/commands/setup.js +575 -0
- package/dist/commands/vault.d.ts +2 -0
- package/dist/commands/vault.js +292 -0
- package/dist/commands/whoami.d.ts +2 -0
- package/dist/commands/whoami.js +16 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +45 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +25 -0
- package/dist/utils/bundle.d.ts +8 -0
- package/dist/utils/bundle.js +59 -0
- package/dist/utils/hash.d.ts +4 -0
- package/dist/utils/hash.js +9 -0
- package/dist/utils/ui.d.ts +19 -0
- package/dist/utils/ui.js +43 -0
- package/dist/utils/validators.d.ts +25 -0
- package/dist/utils/validators.js +65 -0
- package/dist/utils/zip.d.ts +5 -0
- package/dist/utils/zip.js +17 -0
- 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 {};
|