@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
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { detectArchitecture, hasExistingInjection, removeExistingInjection, injectTraditionalAndroid, injectNewArchAndroid, injectIosSetup, } from "../commands/setup.js";
|
|
3
|
+
// ─── detectArchitecture ──────────────────────────────────────────────────────
|
|
4
|
+
describe("detectArchitecture", () => {
|
|
5
|
+
it("returns 'new' for ReactHostDelegate pattern", () => {
|
|
6
|
+
expect(detectArchitecture("class Foo : ReactHostDelegate {}")).toBe("new");
|
|
7
|
+
});
|
|
8
|
+
it("returns 'new' for ReactHostImpl pattern", () => {
|
|
9
|
+
expect(detectArchitecture("val host = ReactHostImpl(ctx, delegate)")).toBe("new");
|
|
10
|
+
});
|
|
11
|
+
it("returns 'new' for DefaultNewArchitectureEntryPoint", () => {
|
|
12
|
+
expect(detectArchitecture("DefaultNewArchitectureEntryPoint.load()")).toBe("new");
|
|
13
|
+
});
|
|
14
|
+
it("returns 'traditional' for ReactNativeHost pattern", () => {
|
|
15
|
+
expect(detectArchitecture("object : ReactNativeHost(this) {}")).toBe("traditional");
|
|
16
|
+
});
|
|
17
|
+
it("returns 'traditional' for getJSBundleFile pattern", () => {
|
|
18
|
+
expect(detectArchitecture("override fun getJSBundleFile(): String? = null")).toBe("traditional");
|
|
19
|
+
});
|
|
20
|
+
it("defaults to 'traditional' when no known patterns present", () => {
|
|
21
|
+
expect(detectArchitecture("class MainApplication : Application() {}")).toBe("traditional");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
// ─── hasExistingInjection ────────────────────────────────────────────────────
|
|
25
|
+
describe("hasExistingInjection", () => {
|
|
26
|
+
it("returns true when both markers are present", () => {
|
|
27
|
+
const content = `before\n// APPSPACER_START\ncode\n// APPSPACER_END\nafter`;
|
|
28
|
+
expect(hasExistingInjection(content)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
it("returns false when only START marker is present", () => {
|
|
31
|
+
expect(hasExistingInjection("// APPSPACER_START\ncode")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
it("returns false when only END marker is present", () => {
|
|
34
|
+
expect(hasExistingInjection("code\n// APPSPACER_END")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
it("returns false when no markers present", () => {
|
|
37
|
+
expect(hasExistingInjection("class MainApplication {}")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
// ─── removeExistingInjection ─────────────────────────────────────────────────
|
|
41
|
+
describe("removeExistingInjection", () => {
|
|
42
|
+
it("removes the injected block including markers", () => {
|
|
43
|
+
const content = [
|
|
44
|
+
"class MainApplication {",
|
|
45
|
+
" // APPSPACER_START",
|
|
46
|
+
" // injected code",
|
|
47
|
+
" // APPSPACER_END",
|
|
48
|
+
"}",
|
|
49
|
+
].join("\n");
|
|
50
|
+
const result = removeExistingInjection(content);
|
|
51
|
+
expect(result).not.toContain("APPSPACER_START");
|
|
52
|
+
expect(result).not.toContain("APPSPACER_END");
|
|
53
|
+
expect(result).not.toContain("injected code");
|
|
54
|
+
expect(result).toContain("class MainApplication {");
|
|
55
|
+
expect(result).toContain("}");
|
|
56
|
+
});
|
|
57
|
+
it("returns content unchanged when no injection present", () => {
|
|
58
|
+
const content = "class MainApplication {}";
|
|
59
|
+
expect(removeExistingInjection(content)).toBe(content);
|
|
60
|
+
});
|
|
61
|
+
it("does not leave excessive blank lines after removal", () => {
|
|
62
|
+
const content = [
|
|
63
|
+
"line1",
|
|
64
|
+
" // APPSPACER_START",
|
|
65
|
+
" code",
|
|
66
|
+
" // APPSPACER_END",
|
|
67
|
+
"line2",
|
|
68
|
+
].join("\n");
|
|
69
|
+
const result = removeExistingInjection(content);
|
|
70
|
+
expect(result).not.toMatch(/\n{3,}/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
// ─── injectTraditionalAndroid ────────────────────────────────────────────────
|
|
74
|
+
describe("injectTraditionalAndroid", () => {
|
|
75
|
+
it("injects inside ReactNativeHost block", () => {
|
|
76
|
+
const content = `
|
|
77
|
+
class MainApplication : Application(), ReactApplication {
|
|
78
|
+
override val reactNativeHost: ReactNativeHost =
|
|
79
|
+
object : DefaultReactNativeHost(this) {
|
|
80
|
+
}
|
|
81
|
+
}`;
|
|
82
|
+
const { result, method } = injectTraditionalAndroid(content);
|
|
83
|
+
expect(result).toContain("getJSBundleFile");
|
|
84
|
+
expect(result).toContain("APPSPACER_START");
|
|
85
|
+
expect(result).toContain("APPSPACER_END");
|
|
86
|
+
expect(method).toMatch(/ReactNativeHost/i);
|
|
87
|
+
});
|
|
88
|
+
it("falls back to class body when no ReactNativeHost block found", () => {
|
|
89
|
+
const content = `
|
|
90
|
+
class MainApplication : Application() {
|
|
91
|
+
fun onCreate() {}
|
|
92
|
+
}`;
|
|
93
|
+
const { result, method } = injectTraditionalAndroid(content);
|
|
94
|
+
expect(result).toContain("APPSPACER_START");
|
|
95
|
+
expect(result).toContain("getJSBundleFile");
|
|
96
|
+
expect(method).toContain("fallback");
|
|
97
|
+
});
|
|
98
|
+
it("injected result passes hasExistingInjection check", () => {
|
|
99
|
+
const content = `class MainApplication : Application() {}`;
|
|
100
|
+
const { result } = injectTraditionalAndroid(content);
|
|
101
|
+
expect(hasExistingInjection(result)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
// ─── injectNewArchAndroid ────────────────────────────────────────────────────
|
|
105
|
+
describe("injectNewArchAndroid", () => {
|
|
106
|
+
it("injects inside ReactHostDelegate block", () => {
|
|
107
|
+
const content = `
|
|
108
|
+
class MainApplication : Application() {
|
|
109
|
+
val delegate = object : ReactHostDelegate {
|
|
110
|
+
}
|
|
111
|
+
}`;
|
|
112
|
+
const { result, method } = injectNewArchAndroid(content);
|
|
113
|
+
expect(result).toContain("getJsBundleFilePath");
|
|
114
|
+
expect(result).toContain("APPSPACER_START");
|
|
115
|
+
expect(method).toMatch(/ReactHostDelegate/i);
|
|
116
|
+
});
|
|
117
|
+
it("adds instruction comment for DefaultReactHost pattern", () => {
|
|
118
|
+
const content = `
|
|
119
|
+
class MainApplication : Application() {
|
|
120
|
+
val host = DefaultReactHost.getDefaultReactHost(applicationContext, packages)
|
|
121
|
+
}`;
|
|
122
|
+
const { result, warn } = injectNewArchAndroid(content);
|
|
123
|
+
expect(result).toContain("APPSPACER_START");
|
|
124
|
+
expect(result).toContain("ACTION REQUIRED");
|
|
125
|
+
expect(warn).toContain("manual action required");
|
|
126
|
+
});
|
|
127
|
+
it("injected result passes hasExistingInjection check", () => {
|
|
128
|
+
const content = `
|
|
129
|
+
class MainApplication : Application() {
|
|
130
|
+
val delegate = object : ReactHostDelegate {}
|
|
131
|
+
}`;
|
|
132
|
+
const { result } = injectNewArchAndroid(content);
|
|
133
|
+
expect(hasExistingInjection(result)).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
// ─── injectIosSetup (Obj-C) ──────────────────────────────────────────────────
|
|
137
|
+
describe("injectIosSetup — Obj-C (.mm)", () => {
|
|
138
|
+
const filePath = "AppDelegate.mm";
|
|
139
|
+
it("replaces bundleURL method body", () => {
|
|
140
|
+
const content = `
|
|
141
|
+
#import "AppDelegate.h"
|
|
142
|
+
|
|
143
|
+
@implementation AppDelegate
|
|
144
|
+
|
|
145
|
+
- (NSURL *)bundleURL {
|
|
146
|
+
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@end`;
|
|
150
|
+
const { result } = injectIosSetup(content, filePath);
|
|
151
|
+
expect(result).toContain("[AppSpacerModule bundleURL]");
|
|
152
|
+
expect(result).toContain("APPSPACER_START");
|
|
153
|
+
});
|
|
154
|
+
it("adds AppSpacerModule.h import if missing", () => {
|
|
155
|
+
const content = `
|
|
156
|
+
#import "AppDelegate.h"
|
|
157
|
+
|
|
158
|
+
@implementation AppDelegate
|
|
159
|
+
|
|
160
|
+
- (NSURL *)bundleURL {
|
|
161
|
+
return nil;
|
|
162
|
+
}
|
|
163
|
+
@end`;
|
|
164
|
+
const { result } = injectIosSetup(content, filePath);
|
|
165
|
+
expect(result).toContain("#import <AppSpacerModule.h>");
|
|
166
|
+
});
|
|
167
|
+
it("does not add duplicate import if already present", () => {
|
|
168
|
+
const content = `
|
|
169
|
+
#import "AppDelegate.h"
|
|
170
|
+
#import <AppSpacerModule.h>
|
|
171
|
+
|
|
172
|
+
@implementation AppDelegate
|
|
173
|
+
- (NSURL *)bundleURL { return nil; }
|
|
174
|
+
@end`;
|
|
175
|
+
const { result } = injectIosSetup(content, filePath);
|
|
176
|
+
const count = (result.match(/#import <AppSpacerModule\.h>/g) || []).length;
|
|
177
|
+
expect(count).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
it("injects bundleURL method before @end as fallback", () => {
|
|
180
|
+
const content = `
|
|
181
|
+
#import "AppDelegate.h"
|
|
182
|
+
@implementation AppDelegate
|
|
183
|
+
@end`;
|
|
184
|
+
const { result } = injectIosSetup(content, filePath);
|
|
185
|
+
expect(result).toContain("[AppSpacerModule bundleURL]");
|
|
186
|
+
expect(result).toContain("APPSPACER_START");
|
|
187
|
+
});
|
|
188
|
+
it("re-applies cleanly (idempotent via remove + re-inject)", () => {
|
|
189
|
+
const content = `
|
|
190
|
+
#import "AppDelegate.h"
|
|
191
|
+
@implementation AppDelegate
|
|
192
|
+
- (NSURL *)bundleURL { return nil; }
|
|
193
|
+
@end`;
|
|
194
|
+
const { result: first } = injectIosSetup(content, filePath);
|
|
195
|
+
const { result: second } = injectIosSetup(first, filePath);
|
|
196
|
+
const startCount = (second.match(/APPSPACER_START/g) || []).length;
|
|
197
|
+
expect(startCount).toBe(1);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
// ─── injectIosSetup (Swift) ──────────────────────────────────────────────────
|
|
201
|
+
describe("injectIosSetup — Swift (.swift)", () => {
|
|
202
|
+
const filePath = "AppDelegate.swift";
|
|
203
|
+
it("replaces bundleURL() override body", () => {
|
|
204
|
+
const content = `
|
|
205
|
+
import UIKit
|
|
206
|
+
|
|
207
|
+
@UIApplicationMain
|
|
208
|
+
class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
209
|
+
override func bundleURL() -> URL? {
|
|
210
|
+
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
|
|
211
|
+
}
|
|
212
|
+
}`;
|
|
213
|
+
const { result } = injectIosSetup(content, filePath);
|
|
214
|
+
expect(result).toContain("AppSpacerModule.bundleURL()");
|
|
215
|
+
expect(result).toContain("APPSPACER_START");
|
|
216
|
+
});
|
|
217
|
+
it("injects bundleURL override before class end as fallback", () => {
|
|
218
|
+
const content = `
|
|
219
|
+
import UIKit
|
|
220
|
+
class AppDelegate: UIResponder {
|
|
221
|
+
func application(_ application: UIApplication) -> Bool { return true }
|
|
222
|
+
}`;
|
|
223
|
+
const { result } = injectIosSetup(content, filePath);
|
|
224
|
+
expect(result).toContain("AppSpacerModule.bundleURL()");
|
|
225
|
+
expect(result).toContain("APPSPACER_START");
|
|
226
|
+
});
|
|
227
|
+
it("re-applies cleanly (idempotent)", () => {
|
|
228
|
+
const content = `
|
|
229
|
+
import UIKit
|
|
230
|
+
class AppDelegate: UIResponder {
|
|
231
|
+
override func bundleURL() -> URL? { return nil }
|
|
232
|
+
}`;
|
|
233
|
+
const { result: first } = injectIosSetup(content, filePath);
|
|
234
|
+
const { result: second } = injectIosSetup(first, filePath);
|
|
235
|
+
const startCount = (second.match(/APPSPACER_START/g) || []).length;
|
|
236
|
+
expect(startCount).toBe(1);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, afterAll } from "vitest";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { createZip } from "../utils/zip.js";
|
|
6
|
+
const TMP_DIR = path.join(os.tmpdir(), "appspacer-zip-tests");
|
|
7
|
+
function setup() {
|
|
8
|
+
const sourceDir = path.join(TMP_DIR, "source");
|
|
9
|
+
const zipPath = path.join(TMP_DIR, "out.zip");
|
|
10
|
+
fs.mkdirSync(sourceDir, { recursive: true });
|
|
11
|
+
return { sourceDir, zipPath };
|
|
12
|
+
}
|
|
13
|
+
afterAll(() => {
|
|
14
|
+
if (fs.existsSync(TMP_DIR))
|
|
15
|
+
fs.rmSync(TMP_DIR, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
describe("createZip", () => {
|
|
18
|
+
it("creates a zip file at the given output path", async () => {
|
|
19
|
+
const { sourceDir, zipPath } = setup();
|
|
20
|
+
fs.writeFileSync(path.join(sourceDir, "bundle.js"), "console.log('hello');");
|
|
21
|
+
await createZip(sourceDir, zipPath);
|
|
22
|
+
expect(fs.existsSync(zipPath)).toBe(true);
|
|
23
|
+
expect(fs.statSync(zipPath).size).toBeGreaterThan(0);
|
|
24
|
+
});
|
|
25
|
+
it("zip file starts with PK magic bytes (valid ZIP header)", async () => {
|
|
26
|
+
const { sourceDir, zipPath } = setup();
|
|
27
|
+
fs.writeFileSync(path.join(sourceDir, "index.js"), "export default 42;");
|
|
28
|
+
await createZip(sourceDir, zipPath);
|
|
29
|
+
const buf = Buffer.alloc(4);
|
|
30
|
+
const fd = fs.openSync(zipPath, "r");
|
|
31
|
+
fs.readSync(fd, buf, 0, 4, 0);
|
|
32
|
+
fs.closeSync(fd);
|
|
33
|
+
// ZIP local file header signature: PK (0x50 0x4B 0x03 0x04)
|
|
34
|
+
expect(buf[0]).toBe(0x50);
|
|
35
|
+
expect(buf[1]).toBe(0x4b);
|
|
36
|
+
});
|
|
37
|
+
it("produces a larger zip when more files are added", async () => {
|
|
38
|
+
const { sourceDir, zipPath: zipA } = setup();
|
|
39
|
+
fs.writeFileSync(path.join(sourceDir, "a.js"), "a");
|
|
40
|
+
await createZip(sourceDir, zipA);
|
|
41
|
+
const sizeA = fs.statSync(zipA).size;
|
|
42
|
+
fs.writeFileSync(path.join(sourceDir, "b.js"), "b".repeat(1000));
|
|
43
|
+
const zipB = path.join(TMP_DIR, "out2.zip");
|
|
44
|
+
await createZip(sourceDir, zipB);
|
|
45
|
+
const sizeB = fs.statSync(zipB).size;
|
|
46
|
+
expect(sizeB).toBeGreaterThan(sizeA);
|
|
47
|
+
});
|
|
48
|
+
it("works with an empty directory", async () => {
|
|
49
|
+
const { sourceDir, zipPath } = setup();
|
|
50
|
+
// sourceDir exists but is empty
|
|
51
|
+
await expect(createZip(sourceDir, zipPath)).resolves.toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
it("packages nested files correctly (no throw)", async () => {
|
|
54
|
+
const { sourceDir, zipPath } = setup();
|
|
55
|
+
const sub = path.join(sourceDir, "assets", "images");
|
|
56
|
+
fs.mkdirSync(sub, { recursive: true });
|
|
57
|
+
fs.writeFileSync(path.join(sub, "logo.png"), "fake-png-data");
|
|
58
|
+
fs.writeFileSync(path.join(sourceDir, "index.js"), "console.log('hi')");
|
|
59
|
+
await expect(createZip(sourceDir, zipPath)).resolves.toBeUndefined();
|
|
60
|
+
expect(fs.statSync(zipPath).size).toBeGreaterThan(0);
|
|
61
|
+
});
|
|
62
|
+
});
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make an authenticated request to the AppSpacer API.
|
|
3
|
+
* - Aborts automatically after 30 seconds.
|
|
4
|
+
* - Retries up to 3 times on HTTP 429, honouring the Retry-After header.
|
|
5
|
+
*/
|
|
6
|
+
export declare function apiRequest<T>(endpoint: string, options?: RequestInit): Promise<T>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { getApiUrl, getToken } from "./config.js";
|
|
2
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
3
|
+
const MAX_RETRIES = 3;
|
|
4
|
+
const RETRY_DELAY_MS = 5_000;
|
|
5
|
+
function sleep(ms) {
|
|
6
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Make an authenticated request to the AppSpacer API.
|
|
10
|
+
* - Aborts automatically after 30 seconds.
|
|
11
|
+
* - Retries up to 3 times on HTTP 429, honouring the Retry-After header.
|
|
12
|
+
*/
|
|
13
|
+
export async function apiRequest(endpoint, options = {}) {
|
|
14
|
+
const url = `${getApiUrl()}${endpoint}`;
|
|
15
|
+
const token = getToken();
|
|
16
|
+
const headers = {
|
|
17
|
+
Authorization: `Bearer ${token}`,
|
|
18
|
+
"Content-Type": "application/json",
|
|
19
|
+
...(options.headers || {}),
|
|
20
|
+
};
|
|
21
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
24
|
+
let response;
|
|
25
|
+
try {
|
|
26
|
+
response = await fetch(url, { ...options, headers, signal: controller.signal });
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
if (err.name === "AbortError") {
|
|
31
|
+
throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s (${endpoint})`);
|
|
32
|
+
}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
// Rate-limited — wait then retry
|
|
39
|
+
if (response.status === 429 && attempt < MAX_RETRIES) {
|
|
40
|
+
const retryAfter = parseInt(response.headers.get("Retry-After") ?? String(RETRY_DELAY_MS / 1000), 10);
|
|
41
|
+
await sleep(retryAfter * 1000);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const body = await response.json();
|
|
45
|
+
if (!response.ok || !body.success) {
|
|
46
|
+
const msg = body.error?.message ?? `Request failed with status ${response.status}`;
|
|
47
|
+
throw new Error(msg);
|
|
48
|
+
}
|
|
49
|
+
return body.data;
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`Request failed after ${MAX_RETRIES} retries (${endpoint})`);
|
|
52
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { apiRequest } from "../api.js";
|
|
5
|
+
const KEY_PREVIEW_LEN = 24;
|
|
6
|
+
export const deploymentsCommand = new Command("deployments")
|
|
7
|
+
.description("List CodePush deployments for an app")
|
|
8
|
+
.requiredOption("-a, --app <appIdOrName>", "App ID or Name")
|
|
9
|
+
.addHelpText("after", `
|
|
10
|
+
Examples:
|
|
11
|
+
$ appspacer deployments -a my-app
|
|
12
|
+
$ appspacer deployments -a abc123`)
|
|
13
|
+
.action(async (opts) => {
|
|
14
|
+
const spinner = ora("Fetching deployments...").start();
|
|
15
|
+
try {
|
|
16
|
+
const deployments = await apiRequest(`/codepush/deployments?app_id=${opts.app}`);
|
|
17
|
+
spinner.stop();
|
|
18
|
+
if (!deployments || deployments.length === 0) {
|
|
19
|
+
console.log(chalk.yellow("\n No deployments found for this app.\n"));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const nameWidth = Math.max(4, ...deployments.map((d) => d.name.length));
|
|
23
|
+
console.log("");
|
|
24
|
+
console.log(` ${chalk.bold("NAME".padEnd(nameWidth))} ${chalk.bold("DEPLOYMENT KEY")}`);
|
|
25
|
+
console.log(chalk.dim(" " + "─".repeat(nameWidth + 3 + KEY_PREVIEW_LEN + 1)));
|
|
26
|
+
for (const d of deployments) {
|
|
27
|
+
const keyPreview = d.key.length > KEY_PREVIEW_LEN
|
|
28
|
+
? d.key.slice(0, KEY_PREVIEW_LEN) + "…"
|
|
29
|
+
: d.key;
|
|
30
|
+
console.log(` ${chalk.cyan(d.name.padEnd(nameWidth))} ${chalk.dim(keyPreview)}`);
|
|
31
|
+
}
|
|
32
|
+
console.log(chalk.dim(`\n ${deployments.length} deployment${deployments.length !== 1 ? "s" : ""} found\n`));
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
spinner.fail("Failed to fetch deployments");
|
|
36
|
+
console.error(chalk.red(` ✗ ${err.message}`));
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import ora from "ora";
|
|
6
|
+
import dotenv from "dotenv";
|
|
7
|
+
import { select } from "@inquirer/prompts";
|
|
8
|
+
import { apiRequest } from "../api.js";
|
|
9
|
+
const ENVSYNC_CONF = path.join(process.cwd(), ".envsync.json");
|
|
10
|
+
const LOCAL_ENV = path.join(process.cwd(), ".env");
|
|
11
|
+
function loadEnvsyncConfig() {
|
|
12
|
+
if (!fs.existsSync(ENVSYNC_CONF)) {
|
|
13
|
+
throw new Error(`Vault not initialized. Run ${chalk.cyan("appspacer envsync init")} first.`);
|
|
14
|
+
}
|
|
15
|
+
return JSON.parse(fs.readFileSync(ENVSYNC_CONF, "utf-8"));
|
|
16
|
+
}
|
|
17
|
+
function saveEnvsyncConfig(config) {
|
|
18
|
+
fs.writeFileSync(ENVSYNC_CONF, JSON.stringify(config, null, 2));
|
|
19
|
+
}
|
|
20
|
+
// ── INIT ────────────────────────────────────────────────────────
|
|
21
|
+
const init = async () => {
|
|
22
|
+
const spinner = ora("Fetching vault projects...").start();
|
|
23
|
+
try {
|
|
24
|
+
const projects = await apiRequest("/vault/projects");
|
|
25
|
+
spinner.stop();
|
|
26
|
+
if (!projects || projects.length === 0) {
|
|
27
|
+
console.log(chalk.red("✖ No Vault projects found. Create one in the AppSpacer dashboard first."));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const projectId = await select({
|
|
31
|
+
message: "Select a vault project:",
|
|
32
|
+
choices: projects.map(p => ({ name: p.name, value: p.id }))
|
|
33
|
+
});
|
|
34
|
+
spinner.start("Fetching environments...");
|
|
35
|
+
const envs = await apiRequest(`/vault/environments/${projectId}`);
|
|
36
|
+
spinner.stop();
|
|
37
|
+
if (!envs || envs.length === 0) {
|
|
38
|
+
console.log(chalk.red("✖ No environments found for this project."));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const envId = await select({
|
|
42
|
+
message: "Select an environment:",
|
|
43
|
+
choices: envs.map(e => ({ name: e.name, value: e.id }))
|
|
44
|
+
});
|
|
45
|
+
const selectedEnv = envs.find(e => e.id === envId);
|
|
46
|
+
saveEnvsyncConfig({ projectId, envId, envName: selectedEnv.name });
|
|
47
|
+
console.log(chalk.green(`✔ Vault initialised!`));
|
|
48
|
+
console.log(` Project: ${projects.find(p => p.id === projectId)?.name}`);
|
|
49
|
+
console.log(` Environment: ${selectedEnv.name}`);
|
|
50
|
+
console.log(` Target: .env\n`);
|
|
51
|
+
console.log(`Run ${chalk.cyan("appspacer envsync pull")} to download variables.`);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
spinner.fail(err.message || "Init failed");
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
// ── PUSH ────────────────────────────────────────────────────────
|
|
58
|
+
const push = async () => {
|
|
59
|
+
try {
|
|
60
|
+
const conf = loadEnvsyncConfig();
|
|
61
|
+
if (!fs.existsSync(LOCAL_ENV)) {
|
|
62
|
+
console.log(chalk.yellow(`⚠ No .env file found in ${process.cwd()}`));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const envString = fs.readFileSync(LOCAL_ENV, "utf-8");
|
|
66
|
+
const parsedCount = Object.keys(dotenv.parse(Buffer.from(envString))).length;
|
|
67
|
+
if (parsedCount === 0) {
|
|
68
|
+
console.log(chalk.yellow("⚠ Local .env is empty. Nothing to push."));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const spinner = ora(`Pushing ${parsedCount} variables to vault ${chalk.bold(conf.envName)}...`).start();
|
|
72
|
+
const res = await apiRequest("/vault/variable/import", {
|
|
73
|
+
method: "POST",
|
|
74
|
+
body: JSON.stringify({ environment_id: conf.envId, envString })
|
|
75
|
+
});
|
|
76
|
+
spinner.succeed(chalk.green(`${res.count} secrets pushed successfully!`));
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
console.log(chalk.red("✖ " + (err.message || "Failed to push secrets")));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
// ── PULL ────────────────────────────────────────────────────────
|
|
83
|
+
const pull = async () => {
|
|
84
|
+
try {
|
|
85
|
+
const conf = loadEnvsyncConfig();
|
|
86
|
+
const spinner = ora(`Pulling secrets from ${chalk.bold(conf.envName)}...`).start();
|
|
87
|
+
const vars = await apiRequest(`/vault/variables/${conf.envId}?decrypt=true`);
|
|
88
|
+
if (vars.length === 0) {
|
|
89
|
+
spinner.info(chalk.yellow("No variables found in this environment."));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Build .env string
|
|
93
|
+
const lines = vars.map(v => `${v.key}=${v.value}`);
|
|
94
|
+
const outString = lines.join("\n") + "\n";
|
|
95
|
+
fs.writeFileSync(LOCAL_ENV, outString);
|
|
96
|
+
spinner.succeed(chalk.green(`${vars.length} secrets pulled to .env`));
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
console.log(chalk.red("✖ " + (err.message || "Failed to pull secrets")));
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
// ── ENV COMMANDS ────────────────────────────────────────────────
|
|
103
|
+
const envCommand = new Command("env").description("Manage synced environments");
|
|
104
|
+
envCommand.command("list")
|
|
105
|
+
.description("List all environments in the current project")
|
|
106
|
+
.action(async () => {
|
|
107
|
+
try {
|
|
108
|
+
const conf = loadEnvsyncConfig();
|
|
109
|
+
const spinner = ora("Fetching environments...").start();
|
|
110
|
+
const envs = await apiRequest(`/vault/environments/${conf.projectId}`);
|
|
111
|
+
spinner.stop();
|
|
112
|
+
console.log(chalk.bold("\nEnvironments:"));
|
|
113
|
+
envs.forEach(e => {
|
|
114
|
+
const mark = e.id === conf.envId ? chalk.green("●") : "○";
|
|
115
|
+
console.log(` ${mark} ${e.id === conf.envId ? chalk.green(e.name) : e.name}`);
|
|
116
|
+
});
|
|
117
|
+
console.log();
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
console.log(chalk.red("✖ " + (err.message || "Failed")));
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
envCommand.command("use")
|
|
124
|
+
.description("Switch the active environment")
|
|
125
|
+
.argument("<env_name>", "Name of the environment to switch to")
|
|
126
|
+
.action(async (envName) => {
|
|
127
|
+
try {
|
|
128
|
+
const conf = loadEnvsyncConfig();
|
|
129
|
+
const spinner = ora(`Finding environment '${envName}'...`).start();
|
|
130
|
+
const envs = await apiRequest(`/vault/environments/${conf.projectId}`);
|
|
131
|
+
const target = envs.find(e => e.name.toLowerCase() === envName.toLowerCase());
|
|
132
|
+
if (!target) {
|
|
133
|
+
spinner.fail(`Environment '${envName}' not found in this project.`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
saveEnvsyncConfig({ ...conf, envId: target.id, envName: target.name });
|
|
137
|
+
spinner.succeed(`Switched to ${chalk.bold(target.name)}`);
|
|
138
|
+
await pull();
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
console.log(chalk.red("✖ " + (err.message || "Failed")));
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
// ── SECRETS COMMANDS ────────────────────────────────────────────
|
|
145
|
+
const secretsCommand = new Command("secrets").description("Inspect and edit individual secrets");
|
|
146
|
+
secretsCommand.command("ls")
|
|
147
|
+
.description("List all remote secrets (values masked)")
|
|
148
|
+
.action(async () => {
|
|
149
|
+
try {
|
|
150
|
+
const conf = loadEnvsyncConfig();
|
|
151
|
+
const spinner = ora(`Fetching secrets from ${conf.envName}...`).start();
|
|
152
|
+
const vars = await apiRequest(`/vault/variables/${conf.envId}`);
|
|
153
|
+
spinner.stop();
|
|
154
|
+
console.log(chalk.bold(`\nSecrets for ${conf.envName}:`));
|
|
155
|
+
if (vars.length === 0) {
|
|
156
|
+
console.log(chalk.gray(" (No secrets found)"));
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
vars.forEach(v => {
|
|
160
|
+
console.log(` ${chalk.cyan(v.key)} = ${chalk.gray(v.value)}`);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
console.log();
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
console.log(chalk.red("✖ " + (err.message || "Failed")));
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
secretsCommand.command("set")
|
|
170
|
+
.description("Set a single remote variable (e.g. KEY=VALUE)")
|
|
171
|
+
.argument("<keyval>", "Format: KEY=VALUE")
|
|
172
|
+
.action(async (keyval) => {
|
|
173
|
+
const parts = keyval.split("=");
|
|
174
|
+
if (parts.length < 2) {
|
|
175
|
+
console.log(chalk.red("✖ Invalid format. Usage: appspacer envsync secrets set KEY=VALUE"));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const key = parts[0].trim();
|
|
179
|
+
const value = parts.slice(1).join("=").trim(); // in case value has =
|
|
180
|
+
try {
|
|
181
|
+
const conf = loadEnvsyncConfig();
|
|
182
|
+
const spinner = ora(`Setting ${key}...`).start();
|
|
183
|
+
await apiRequest("/vault/variable/create", {
|
|
184
|
+
method: "POST",
|
|
185
|
+
body: JSON.stringify({ environment_id: conf.envId, key, value })
|
|
186
|
+
});
|
|
187
|
+
spinner.succeed(`Set ${chalk.cyan(key)} in ${chalk.bold(conf.envName)}`);
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
console.log(chalk.red("✖ " + (err.message || "Failed")));
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
// ── AUDIT ───────────────────────────────────────────────────────
|
|
194
|
+
const audit = async () => {
|
|
195
|
+
try {
|
|
196
|
+
const conf = loadEnvsyncConfig();
|
|
197
|
+
const spinner = ora(`Fetching audit logs...`).start();
|
|
198
|
+
const logs = await apiRequest(`/vault/audit/${conf.envId}`);
|
|
199
|
+
spinner.stop();
|
|
200
|
+
console.log(chalk.bold(`\nAudit Logs - ${conf.envName}`));
|
|
201
|
+
if (logs.length === 0) {
|
|
202
|
+
console.log(chalk.gray(" No audit history found."));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
logs.forEach(log => {
|
|
206
|
+
const userStr = log.users ? `${log.users.first_name} ${log.users.last_name}` : "System";
|
|
207
|
+
const dateStr = new Date(log.created_at).toLocaleString();
|
|
208
|
+
let actionStr = chalk.bold(log.action);
|
|
209
|
+
if (log.action === "PULL")
|
|
210
|
+
actionStr = chalk.cyan(actionStr);
|
|
211
|
+
else if (log.action === "PUSH")
|
|
212
|
+
actionStr = chalk.yellow(actionStr);
|
|
213
|
+
else if (log.action === "SET_VAR")
|
|
214
|
+
actionStr = chalk.green(actionStr);
|
|
215
|
+
console.log(`${chalk.gray(dateStr)} | ${userStr} | ${actionStr} | ${chalk.gray(JSON.stringify(log.metadata))}`);
|
|
216
|
+
});
|
|
217
|
+
console.log();
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
console.log(chalk.red("✖ " + (err.message || "Failed")));
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
export const envsyncCommand = new Command("envsync")
|
|
224
|
+
.description("Manage and sync environment variables via AppSpacer Vault");
|
|
225
|
+
envsyncCommand.command("init").description("Initialize an envsync vault in the current directory").action(init);
|
|
226
|
+
envsyncCommand.command("push").description("Push local .env to remote vault").action(push);
|
|
227
|
+
envsyncCommand.command("pull").description("Pull variables from remote vault into local .env").action(pull);
|
|
228
|
+
envsyncCommand.command("audit").description("View recent audit history for the synced environment").action(audit);
|
|
229
|
+
envsyncCommand.addCommand(envCommand);
|
|
230
|
+
envsyncCommand.addCommand(secretsCommand);
|