@appforge-ci/admin-cli 0.1.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/commands/agents.d.ts +55 -0
  4. package/dist/commands/agents.d.ts.map +1 -0
  5. package/dist/commands/agents.js +436 -0
  6. package/dist/commands/agents.js.map +1 -0
  7. package/dist/commands/agents.test.d.ts +2 -0
  8. package/dist/commands/agents.test.d.ts.map +1 -0
  9. package/dist/commands/agents.test.js +127 -0
  10. package/dist/commands/agents.test.js.map +1 -0
  11. package/dist/commands/login.d.ts +38 -0
  12. package/dist/commands/login.d.ts.map +1 -0
  13. package/dist/commands/login.js +128 -0
  14. package/dist/commands/login.js.map +1 -0
  15. package/dist/commands/login.test.d.ts +2 -0
  16. package/dist/commands/login.test.d.ts.map +1 -0
  17. package/dist/commands/login.test.js +69 -0
  18. package/dist/commands/login.test.js.map +1 -0
  19. package/dist/index.d.ts +3 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +34 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/lib/bootstrap.d.ts +21 -0
  24. package/dist/lib/bootstrap.d.ts.map +1 -0
  25. package/dist/lib/bootstrap.js +242 -0
  26. package/dist/lib/bootstrap.js.map +1 -0
  27. package/dist/lib/bootstrap.test.d.ts +2 -0
  28. package/dist/lib/bootstrap.test.d.ts.map +1 -0
  29. package/dist/lib/bootstrap.test.js +59 -0
  30. package/dist/lib/bootstrap.test.js.map +1 -0
  31. package/dist/lib/config.d.ts +27 -0
  32. package/dist/lib/config.d.ts.map +1 -0
  33. package/dist/lib/config.js +61 -0
  34. package/dist/lib/config.js.map +1 -0
  35. package/dist/lib/config.test.d.ts +2 -0
  36. package/dist/lib/config.test.d.ts.map +1 -0
  37. package/dist/lib/config.test.js +120 -0
  38. package/dist/lib/config.test.js.map +1 -0
  39. package/dist/lib/prompt.d.ts +11 -0
  40. package/dist/lib/prompt.d.ts.map +1 -0
  41. package/dist/lib/prompt.js +54 -0
  42. package/dist/lib/prompt.js.map +1 -0
  43. package/dist/lib/prompt.test.d.ts +2 -0
  44. package/dist/lib/prompt.test.d.ts.map +1 -0
  45. package/dist/lib/prompt.test.js +22 -0
  46. package/dist/lib/prompt.test.js.map +1 -0
  47. package/dist/lib/two-factor.d.ts +21 -0
  48. package/dist/lib/two-factor.d.ts.map +1 -0
  49. package/dist/lib/two-factor.js +31 -0
  50. package/dist/lib/two-factor.js.map +1 -0
  51. package/dist/lib/two-factor.test.d.ts +2 -0
  52. package/dist/lib/two-factor.test.d.ts.map +1 -0
  53. package/dist/lib/two-factor.test.js +51 -0
  54. package/dist/lib/two-factor.test.js.map +1 -0
  55. package/dist/test-support/home-sandbox.d.ts +3 -0
  56. package/dist/test-support/home-sandbox.d.ts.map +1 -0
  57. package/dist/test-support/home-sandbox.js +47 -0
  58. package/dist/test-support/home-sandbox.js.map +1 -0
  59. package/package.json +33 -0
@@ -0,0 +1,242 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { installAgentLaunchAgent, stableNodePath } from "@appforge-ci/agent/dist/lib/launchd.js";
7
+ import { loadConfig } from "@appforge-ci/agent/dist/lib/config.js";
8
+ import { AgentApi } from "@appforge-ci/agent/dist/lib/api.js";
9
+ import { installDeviceStreamIfPresent, syncDeviceStreamFleetSecrets } from "@appforge-ci/agent/dist/lib/device-stream-install.js";
10
+ const require = createRequire(import.meta.url);
11
+ /**
12
+ * Automates the toolchain-install half of infra/mac-mini-setup/bootstrap.sh
13
+ * from inside the CLI, split into the same two tiers we drew by hand while
14
+ * enrolling a personal laptop as a runner (see #17):
15
+ *
16
+ * - `bootstrapSafeToolchains` (default, always run): Homebrew installs and
17
+ * PATH wiring only. No `sudo`, no system-setting changes — safe on a
18
+ * machine you actually use day to day, not just a dedicated Mac mini.
19
+ * - `bootstrapPrivileged` (opt-in via `agents bootstrap --full`): the
20
+ * parts of bootstrap.sh that touch system/security settings (a
21
+ * passwordless-sudo sudoers rule for remote reboot, disabling sleep,
22
+ * reinstalling Xcode). Appropriate for a dedicated always-on mini, not
23
+ * a laptop — never runs unless explicitly asked for.
24
+ *
25
+ * Reuses bootstrap.sh's actual commands (brew formula/cask names, the
26
+ * sdkmanager package list, the sudoers line, the pmset flags) rather than
27
+ * re-deriving them, so the two don't drift apart — see that script for the
28
+ * canonical version if you're changing either.
29
+ */
30
+ function run(cmd, args) {
31
+ console.log(` $ ${cmd} ${args.join(" ")}`);
32
+ execFileSync(cmd, args, { stdio: "inherit" });
33
+ }
34
+ function tryRun(cmd, args) {
35
+ try {
36
+ execFileSync(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
37
+ return true;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ function brewFormulaInstalled(name) {
44
+ return tryRun("brew", ["list", "--formula", name]);
45
+ }
46
+ function brewCaskInstalled(name) {
47
+ return tryRun("brew", ["list", "--cask", name]);
48
+ }
49
+ const PATH_BLOCK_START = "# >>> appforge-agent toolchains (added by `appforge-admin agents bootstrap`) >>>";
50
+ const PATH_BLOCK_END = "# <<< appforge-agent toolchains <<<";
51
+ /**
52
+ * Appends a PATH block once, idempotently — re-running `agents bootstrap`
53
+ * (e.g. after installing a toolchain that was missing the first time)
54
+ * shouldn't pile up duplicate exports on every run.
55
+ *
56
+ * Exported for bootstrap.test.ts — every other caller in this file only
57
+ * ever uses it as an internal helper, but its idempotency and
58
+ * existing-content-preservation are worth testing directly rather than
59
+ * only indirectly through bootstrapSafeToolchains(), which can't be
60
+ * exercised at all in a headless environment (it shells out to a real
61
+ * `brew`/`sdkmanager`/Homebrew toolchain install).
62
+ */
63
+ export function ensurePathBlock(profilePath, lines) {
64
+ const existing = existsSync(profilePath) ? readFileSync(profilePath, "utf8") : "";
65
+ if (existing.includes(PATH_BLOCK_START)) {
66
+ console.log(` ${profilePath} already has the toolchain PATH block, leaving it alone`);
67
+ return;
68
+ }
69
+ const block = `\n${PATH_BLOCK_START}\n${lines.join("\n")}\n${PATH_BLOCK_END}\n`;
70
+ appendFileSync(profilePath, block);
71
+ console.log(` wrote toolchain PATH block to ${profilePath}`);
72
+ }
73
+ async function reloadAgentIfEnrolled() {
74
+ const plistPath = join(homedir(), "Library", "LaunchAgents", "com.appforge.agent.plist");
75
+ if (!existsSync(plistPath))
76
+ return; // not enrolled yet — add-mac will pick up fresh toolchains on first run
77
+ console.log("\n==> Reloading the already-enrolled agent so it re-detects toolchains immediately");
78
+ // Re-runs installAgentLaunchAgent (unload+rewrite+load), not a bare
79
+ // unload/load of the existing plist verbatim — a mini enrolled before
80
+ // #14 published the CLIs to npm has a plist written by the old,
81
+ // now-deleted install.sh pointing at a copied
82
+ // /usr/local/lib/appforge-agent/dist/index.js. A bare reload would just
83
+ // keep launching that stale copy forever; rewriting the plist points it
84
+ // at wherever @appforge-ci/agent actually resolves from *now* (this
85
+ // package's own dependency, whether that's an in-repo checkout or a
86
+ // global npm install), the same as `add-mac` already does.
87
+ const agentDistIndexPath = require.resolve("@appforge-ci/agent/dist/index.js");
88
+ // Same fix as add-mac's own installAgentLaunchAgent call (see agents.ts)
89
+ // — resolve the stable unversioned node symlink before baking it into
90
+ // the plist, so a later `brew upgrade node` doesn't strand launchd
91
+ // pointed at a deleted version-pinned Cellar path.
92
+ const nodePath = stableNodePath(process.execPath);
93
+ installAgentLaunchAgent(nodePath, agentDistIndexPath);
94
+ // Same device-stream install-service fold `add-mac` gets (see
95
+ // agents.ts) — a mini bootstrapped, then reloaded, through this path
96
+ // should end up with Screen Sharing/"Test in browser" wired up too,
97
+ // not just through add-mac. Loads this machine's own already-enrolled
98
+ // agent config (the plist-exists check above already established this
99
+ // machine is enrolled) purely to get the agentId/agentToken this
100
+ // needs; any failure here (a corrupt/missing config despite the plist
101
+ // existing, a network hiccup) is swallowed — reloading the agent's own
102
+ // service above is what this function must always succeed at, never
103
+ // device-stream's setup on top of it.
104
+ try {
105
+ const config = loadConfig();
106
+ const api = new AgentApi(config);
107
+ let outcome;
108
+ try {
109
+ ({ outcome } = await syncDeviceStreamFleetSecrets({
110
+ api,
111
+ agentDistIndexPath,
112
+ nodePath,
113
+ apiUrl: config.apiUrl,
114
+ env: process.env,
115
+ agentId: config.agentId,
116
+ }));
117
+ }
118
+ catch (err) {
119
+ console.warn(`Couldn't fetch this org's fleet secrets (${err.message}) — ` +
120
+ `falling back to whatever is set in this shell's environment.`);
121
+ outcome = await installDeviceStreamIfPresent(agentDistIndexPath, nodePath, config.apiUrl, process.env, {}, undefined, undefined, config.agentId);
122
+ }
123
+ if (outcome.message)
124
+ console.log(outcome.message);
125
+ }
126
+ catch (err) {
127
+ console.warn(`Skipping apps/device-stream's install-service reload: ${err.message}`);
128
+ }
129
+ }
130
+ export async function bootstrapSafeToolchains() {
131
+ if (!tryRun("brew", ["--version"])) {
132
+ console.error("Homebrew isn't installed. Install it first (https://brew.sh), then re-run this command.");
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+ console.log("==> iOS toolchain: CocoaPods");
137
+ if (brewFormulaInstalled("cocoapods")) {
138
+ console.log(" already installed");
139
+ }
140
+ else {
141
+ run("brew", ["install", "cocoapods"]);
142
+ }
143
+ console.log("\n==> Android toolchain: Java (openjdk)");
144
+ if (!brewFormulaInstalled("openjdk")) {
145
+ run("brew", ["install", "openjdk"]);
146
+ }
147
+ else {
148
+ console.log(" already installed");
149
+ }
150
+ console.log("\n==> Android toolchain: SDK command-line tools");
151
+ // Prefer an SDK that's already on disk (e.g. from Android Studio) over
152
+ // installing a second one via the Homebrew cask — either way, what
153
+ // actually matters for the agent is that ANDROID_HOME/PATH point at
154
+ // platform-tools (adb) somewhere real.
155
+ const existingSdk = join(homedir(), "Library", "Android", "sdk");
156
+ const brewPrefix = execFileSync("brew", ["--prefix"], { encoding: "utf8" }).trim(); // /opt/homebrew on Apple Silicon, /usr/local on Intel
157
+ let androidHome;
158
+ if (existsSync(join(existingSdk, "platform-tools", "adb"))) {
159
+ console.log(` found an existing SDK at ${existingSdk}, using it`);
160
+ androidHome = existingSdk;
161
+ }
162
+ else if (brewCaskInstalled("android-commandlinetools")) {
163
+ androidHome = `${brewPrefix}/share/android-commandlinetools`;
164
+ console.log(` already installed via Homebrew cask (${androidHome})`);
165
+ }
166
+ else {
167
+ run("brew", ["install", "--cask", "android-commandlinetools"]);
168
+ androidHome = `${brewPrefix}/share/android-commandlinetools`;
169
+ // sdkmanager is itself a Java program — needs `java` on PATH to run at
170
+ // all, which openjdk being keg-only means isn't there by default (the
171
+ // exact bug this function's whole PATH-wiring step exists to fix).
172
+ const openjdkBin = execFileSync("brew", ["--prefix", "openjdk"], { encoding: "utf8" }).trim() + "/bin";
173
+ const sdkEnv = { ...process.env, ANDROID_HOME: androidHome, PATH: `${openjdkBin}:${process.env.PATH ?? ""}` };
174
+ const sdkmanager = `${androidHome}/cmdline-tools/latest/bin/sdkmanager`;
175
+ try {
176
+ execFileSync("bash", ["-c", `yes | ${sdkmanager} --licenses`], { stdio: "ignore", env: sdkEnv });
177
+ console.log(` $ ${sdkmanager} platform-tools platforms;android-34 build-tools;34.0.0`);
178
+ execFileSync(sdkmanager, ["platform-tools", "platforms;android-34", "build-tools;34.0.0"], { stdio: "inherit", env: sdkEnv });
179
+ }
180
+ catch {
181
+ console.warn(" sdkmanager package install failed — you may need to accept licenses manually (sdkmanager --licenses)");
182
+ }
183
+ }
184
+ console.log("\n==> Wiring Java + Android SDK onto the login-shell PATH the agent's toolchain checks use");
185
+ // openjdk is keg-only (Homebrew deliberately doesn't symlink it, to avoid
186
+ // fighting the system `java` wrapper) so it always needs an explicit
187
+ // PATH entry — see packages/agent/src/lib/system-info.ts's tryRunLogin
188
+ // for why this has to land in a *login* shell's profile specifically.
189
+ const pathLines = [
190
+ `export ANDROID_HOME="${androidHome}"`,
191
+ `export PATH="$(brew --prefix openjdk)/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"`,
192
+ // pod --version's first line is a "requires UTF-8 encoding" warning
193
+ // under the "C"/POSIX locale, which packages/agent/src/lib/system-info.ts's
194
+ // firstLine() would capture as the "version" instead of the real one.
195
+ `export LANG=en_US.UTF-8`,
196
+ ];
197
+ ensurePathBlock(join(homedir(), ".bash_profile"), pathLines); // read by the agent's `bash -lc` checks
198
+ ensurePathBlock(join(homedir(), ".zshrc"), pathLines); // read by your interactive shell
199
+ await reloadAgentIfEnrolled();
200
+ console.log("\nDone. Run 'appforge-admin agents add-mac' (or check the Fleet dashboard, or 'agents list') to confirm toolchains are now detected.");
201
+ }
202
+ /**
203
+ * The system/security-sensitive half of bootstrap.sh — never run unless
204
+ * explicitly requested via `--full`. Appropriate for a dedicated always-on
205
+ * Mac mini; not something to do to a machine you use day to day.
206
+ */
207
+ export function bootstrapPrivileged() {
208
+ console.log("\n==> Passwordless sudo for the REBOOT_HOST remote command (scoped, not blanket sudo)");
209
+ const agentUser = execFileSync("whoami", [], { encoding: "utf8" }).trim();
210
+ const sudoersLine = `${agentUser} ALL=(root) NOPASSWD: /sbin/shutdown -r now`;
211
+ const sudoersFile = "/etc/sudoers.d/appforge-agent";
212
+ if (existsSync(sudoersFile)) {
213
+ console.log(` ${sudoersFile} already exists, leaving it alone`);
214
+ }
215
+ else {
216
+ run("bash", ["-c", `echo '${sudoersLine}' | sudo tee ${sudoersFile} >/dev/null && sudo chmod 440 ${sudoersFile}`]);
217
+ console.log(` wrote ${sudoersFile}`);
218
+ }
219
+ console.log("\n==> Disabling sleep so this machine keeps building overnight");
220
+ run("sudo", ["pmset", "-a", "sleep", "0", "disksleep", "0", "displaysleep", "10"]);
221
+ console.log("\n==> Full Xcode (Command Line Tools alone can't run xcodebuild)");
222
+ let hasFullXcode = false;
223
+ try {
224
+ hasFullXcode = execFileSync("xcode-select", ["-p"], { encoding: "utf8" }).includes("/Xcode.app");
225
+ }
226
+ catch {
227
+ /* xcode-select -p failing just means CLT/Xcode isn't selected yet */
228
+ }
229
+ if (hasFullXcode) {
230
+ console.log(" already installed");
231
+ }
232
+ else if (process.env.XCODES_USERNAME && process.env.XCODES_PASSWORD) {
233
+ if (!brewFormulaInstalled("xcodes"))
234
+ run("brew", ["install", "xcodes", "aria2"]);
235
+ run("xcodes", ["install", "--latest", "--experimental-unxip", "--select"]);
236
+ run("sudo", ["xcodebuild", "-license", "accept"]);
237
+ }
238
+ else {
239
+ console.log(" XCODES_USERNAME/XCODES_PASSWORD not set — skipping. Export those (an Apple ID with App Store access) and re-run with --full, or install by hand: 'xcodes install --latest --select'.");
240
+ }
241
+ }
242
+ //# sourceMappingURL=bootstrap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bootstrap.js","sourceRoot":"","sources":["../../src/lib/bootstrap.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import "../test-support/home-sandbox.js";
2
+ //# sourceMappingURL=bootstrap.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bootstrap.test.d.ts","sourceRoot":"","sources":["../../src/lib/bootstrap.test.ts"],"names":[],"mappings":"AACA,OAAO,iCAAiC,CAAC"}
@@ -0,0 +1,59 @@
1
+ // Must stay the FIRST import — see the comment below the imports.
2
+ import "../test-support/home-sandbox.js";
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { ensurePathBlock } from "./bootstrap.js";
9
+ // The rest of bootstrap.ts (bootstrapSafeToolchains/bootstrapPrivileged)
10
+ // shells out to a real brew/sdkmanager/Homebrew toolchain install and
11
+ // can't be exercised at all in this headless environment — see
12
+ // android-smoke-test.test.ts's own docblock for the same "no real
13
+ // toolchain here" boundary. ensurePathBlock is the one piece of pure,
14
+ // parameterized filesystem logic in this file, so it's the one thing
15
+ // tested directly.
16
+ test("ensurePathBlock creates the file with the marked block when none exists", async () => {
17
+ const dir = await mkdtemp(join(tmpdir(), "appforge-bootstrap-test-"));
18
+ try {
19
+ const profilePath = join(dir, ".bash_profile");
20
+ ensurePathBlock(profilePath, ['export ANDROID_HOME="/fake/sdk"']);
21
+ const content = await readFile(profilePath, "utf8");
22
+ assert.match(content, /appforge-agent toolchains \(added by `appforge-admin agents bootstrap`\)/);
23
+ assert.match(content, /export ANDROID_HOME="\/fake\/sdk"/);
24
+ assert.match(content, /<<< appforge-agent toolchains <<</);
25
+ }
26
+ finally {
27
+ await rm(dir, { recursive: true, force: true });
28
+ }
29
+ });
30
+ test("ensurePathBlock preserves whatever was already in the file, appending after it", async () => {
31
+ const dir = await mkdtemp(join(tmpdir(), "appforge-bootstrap-test-"));
32
+ try {
33
+ const profilePath = join(dir, ".zshrc");
34
+ await writeFile(profilePath, "# a real user's own existing config\nexport EDITOR=vim\n");
35
+ ensurePathBlock(profilePath, ['export PATH="/fake/bin:$PATH"']);
36
+ const content = await readFile(profilePath, "utf8");
37
+ assert.match(content, /^# a real user's own existing config\nexport EDITOR=vim\n/);
38
+ assert.match(content, /export PATH="\/fake\/bin:\$PATH"/);
39
+ }
40
+ finally {
41
+ await rm(dir, { recursive: true, force: true });
42
+ }
43
+ });
44
+ test("ensurePathBlock is idempotent — re-running it never duplicates the block", async () => {
45
+ const dir = await mkdtemp(join(tmpdir(), "appforge-bootstrap-test-"));
46
+ try {
47
+ const profilePath = join(dir, ".bash_profile");
48
+ ensurePathBlock(profilePath, ['export ANDROID_HOME="/fake/sdk"']);
49
+ ensurePathBlock(profilePath, ['export ANDROID_HOME="/fake/sdk"']);
50
+ ensurePathBlock(profilePath, ['export ANDROID_HOME="/fake/sdk"']);
51
+ const content = await readFile(profilePath, "utf8");
52
+ const occurrences = content.split("appforge-agent toolchains (added by").length - 1;
53
+ assert.equal(occurrences, 1, `expected exactly one PATH block, found ${occurrences}`);
54
+ }
55
+ finally {
56
+ await rm(dir, { recursive: true, force: true });
57
+ }
58
+ });
59
+ //# sourceMappingURL=bootstrap.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bootstrap.test.js","sourceRoot":"","sources":["../../src/lib/bootstrap.test.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,OAAO,iCAAiC,CAAC;AACzC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAEjD,yEAAyE;AACzE,sEAAsE;AACtE,+DAA+D;AAC/D,kEAAkE;AAClE,sEAAsE;AACtE,qEAAqE;AACrE,mBAAmB;AAEnB,IAAI,CAAC,yEAAyE,EAAE,KAAK,IAAI,EAAE;IACzF,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,0BAA0B,CAAC,CAAC,CAAC;IACtE,IAAI,CAAC;QACH,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,EAAE,eAAe,CAAC,CAAC;QAC/C,eAAe,CAAC,WAAW,EAAE,CAAC,iCAAiC,CAAC,CAAC,CAAC;QAElE,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QACpD,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,0EAA0E,CAAC,CAAC;QAClG,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,mCAAmC,CAAC,CAAC;QAC3D,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,mCAAmC,CAAC,CAAC;IAC7D,CAAC;YAAS,CAAC;QACT,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAClD,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,gFAAgF,EAAE,KAAK,IAAI,EAAE;IAChG,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,0BAA0B,CAAC,CAAC,CAAC;IACtE,IAAI,CAAC;QACH,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;QACxC,MAAM,SAAS,CAAC,WAAW,EAAE,0DAA0D,CAAC,CAAC;QAEzF,eAAe,CAAC,WAAW,EAAE,CAAC,+BAA+B,CAAC,CAAC,CAAC;QAEhE,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QACpD,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,2DAA2D,CAAC,CAAC;QACnF,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,kCAAkC,CAAC,CAAC;IAC5D,CAAC;YAAS,CAAC;QACT,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAClD,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,0EAA0E,EAAE,KAAK,IAAI,EAAE;IAC1F,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,0BAA0B,CAAC,CAAC,CAAC;IACtE,IAAI,CAAC;QACH,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,EAAE,eAAe,CAAC,CAAC;QAC/C,eAAe,CAAC,WAAW,EAAE,CAAC,iCAAiC,CAAC,CAAC,CAAC;QAClE,eAAe,CAAC,WAAW,EAAE,CAAC,iCAAiC,CAAC,CAAC,CAAC;QAClE,eAAe,CAAC,WAAW,EAAE,CAAC,iCAAiC,CAAC,CAAC,CAAC;QAElE,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QACpD,MAAM,WAAW,GAAG,OAAO,CAAC,KAAK,CAAC,qCAAqC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;QACpF,MAAM,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,EAAE,0CAA0C,WAAW,EAAE,CAAC,CAAC;IACxF,CAAC;YAAS,CAAC;QACT,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAClD,CAAC;AACH,CAAC,CAAC,CAAC"}
@@ -0,0 +1,27 @@
1
+ export interface AdminCliConfig {
2
+ apiUrl: string;
3
+ adminApiUrl: string;
4
+ token: string;
5
+ orgId: string;
6
+ }
7
+ /**
8
+ * The "file missing" case already threw a clear message, but a
9
+ * present-but-malformed config.json (a hand-edit typo, disk-full/
10
+ * killed-mid-write, a cloud-synced homedir's conflicted copy) used to
11
+ * throw a raw JSON.parse SyntaxError instead — every CLI command loads
12
+ * this file, so a corrupted one made the CLI permanently unusable with
13
+ * only that unhelpful message to go on (index.ts's top-level .catch()
14
+ * just prints err.message and exits). Same "catch and re-throw with a
15
+ * clear message" shape packages/agent's own loadConfig() already
16
+ * established for the identical bug there.
17
+ */
18
+ export declare function loadAdminCliConfig(): AdminCliConfig;
19
+ /**
20
+ * Atomic write (temp file + rename) — same pattern packages/agent's own
21
+ * saveConfig() already establishes there for the identical config-file
22
+ * contract, since a process killed mid-write is the most plausible real
23
+ * cause of the corrupted state loadAdminCliConfig() now has to survive.
24
+ */
25
+ export declare function saveAdminCliConfig(config: AdminCliConfig): void;
26
+ export declare function adminCliConfigPath(): string;
27
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,IAAI,cAAc,CAYnD;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,cAAc,QAkBxD;AAED,wBAAgB,kBAAkB,WAEjC"}
@@ -0,0 +1,61 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, renameSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ // Deliberately a separate config directory from @appforge-ci/cli's
5
+ // ~/.appforge/ — these are two different tools for two different
6
+ // audiences (see this package's README), so being logged into one
7
+ // never implies anything about the other.
8
+ const CONFIG_DIR = join(homedir(), ".appforge-admin");
9
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
10
+ /**
11
+ * The "file missing" case already threw a clear message, but a
12
+ * present-but-malformed config.json (a hand-edit typo, disk-full/
13
+ * killed-mid-write, a cloud-synced homedir's conflicted copy) used to
14
+ * throw a raw JSON.parse SyntaxError instead — every CLI command loads
15
+ * this file, so a corrupted one made the CLI permanently unusable with
16
+ * only that unhelpful message to go on (index.ts's top-level .catch()
17
+ * just prints err.message and exits). Same "catch and re-throw with a
18
+ * clear message" shape packages/agent's own loadConfig() already
19
+ * established for the identical bug there.
20
+ */
21
+ export function loadAdminCliConfig() {
22
+ if (!existsSync(CONFIG_PATH)) {
23
+ throw new Error(`Not logged in. Run "appforge-admin login" first (looked for ${CONFIG_PATH}).`);
24
+ }
25
+ try {
26
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
27
+ }
28
+ catch (err) {
29
+ throw new Error(`Config at ${CONFIG_PATH} is corrupted (${err.message}). ` +
30
+ `Re-run "appforge-admin login" to regenerate it.`);
31
+ }
32
+ }
33
+ /**
34
+ * Atomic write (temp file + rename) — same pattern packages/agent's own
35
+ * saveConfig() already establishes there for the identical config-file
36
+ * contract, since a process killed mid-write is the most plausible real
37
+ * cause of the corrupted state loadAdminCliConfig() now has to survive.
38
+ */
39
+ export function saveAdminCliConfig(config) {
40
+ mkdirSync(CONFIG_DIR, { recursive: true });
41
+ const tmpPath = `${CONFIG_PATH}.${process.pid}.tmp`;
42
+ writeFileSync(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600 });
43
+ renameSync(tmpPath, CONFIG_PATH);
44
+ // Belt-and-suspenders, not a fix for a live gap — same correction as
45
+ // @appforge-ci/cli's own config.ts: renameSync() replaces CONFIG_PATH's
46
+ // directory entry with tmpPath's inode wholesale, so the destination
47
+ // already ends up at tmpPath's own mode — 0o600, set by the
48
+ // writeFileSync above, which is *always* creating a brand-new file
49
+ // (tmpPath is unique per process) — regardless of whatever permissions
50
+ // CONFIG_PATH itself had before (verified directly: this repo's own
51
+ // config.test.ts still passes its "re-tightens" assertion with this
52
+ // chmodSync call deleted entirely). Kept anyway as cheap insurance
53
+ // against a *future* edit to saveAdminCliConfig that moves away from
54
+ // the atomic temp-file+rename pattern (e.g. writing in place to an
55
+ // existing CONFIG_PATH) without revisiting this line.
56
+ chmodSync(CONFIG_PATH, 0o600);
57
+ }
58
+ export function adminCliConfigPath() {
59
+ return CONFIG_PATH;
60
+ }
61
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACpG,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,mEAAmE;AACnE,iEAAiE;AACjE,kEAAkE;AAClE,0CAA0C;AAC1C,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,EAAE,EAAE,iBAAiB,CAAC,CAAC;AACtD,MAAM,WAAW,GAAG,IAAI,CAAC,UAAU,EAAE,aAAa,CAAC,CAAC;AASpD;;;;;;;;;;GAUG;AACH,MAAM,UAAU,kBAAkB;IAChC,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;QAC7B,MAAM,IAAI,KAAK,CAAC,+DAA+D,WAAW,IAAI,CAAC,CAAC;IAClG,CAAC;IACD,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC;IACvD,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACb,aAAa,WAAW,kBAAmB,GAAa,CAAC,OAAO,KAAK;YACnE,iDAAiD,CACpD,CAAC;IACJ,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAAsB;IACvD,SAAS,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3C,MAAM,OAAO,GAAG,GAAG,WAAW,IAAI,OAAO,CAAC,GAAG,MAAM,CAAC;IACpD,aAAa,CAAC,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACzE,UAAU,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC;IACjC,qEAAqE;IACrE,wEAAwE;IACxE,qEAAqE;IACrE,4DAA4D;IAC5D,mEAAmE;IACnE,uEAAuE;IACvE,oEAAoE;IACpE,oEAAoE;IACpE,mEAAmE;IACnE,qEAAqE;IACrE,mEAAmE;IACnE,sDAAsD;IACtD,SAAS,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC;AAChC,CAAC;AAED,MAAM,UAAU,kBAAkB;IAChC,OAAO,WAAW,CAAC;AACrB,CAAC"}
@@ -0,0 +1,2 @@
1
+ import "../test-support/home-sandbox.js";
2
+ //# sourceMappingURL=config.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.test.d.ts","sourceRoot":"","sources":["../../src/lib/config.test.ts"],"names":[],"mappings":"AACA,OAAO,iCAAiC,CAAC"}
@@ -0,0 +1,120 @@
1
+ // Must stay the FIRST import — see the comment below the imports.
2
+ import "../test-support/home-sandbox.js";
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { rmSync, writeFileSync, statSync, mkdirSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+ import { loadAdminCliConfig, saveAdminCliConfig, adminCliConfigPath } from "./config.js";
8
+ // adminCliConfigPath() is derived from os.homedir() when ./config.js is
9
+ // first evaluated, and these tests write to and rmSync() it as their
10
+ // fixture — so the sandbox-home guard above MUST stay the first import:
11
+ // it points $HOME at a throwaway temp dir before that constant is
12
+ // computed. (Until 2026-09-13 this file deliberately ran against the
13
+ // real ~/.appforge-admin/config.json — every `pnpm test` here wiped the
14
+ // platform admin's login, first noticed on 2026-09-09; see
15
+ // ../test-support/home-sandbox.ts.) Every test still tears its own
16
+ // fixture down in a finally.
17
+ function permBits(path) {
18
+ return statSync(path).mode & 0o777;
19
+ }
20
+ function cleanup() {
21
+ rmSync(adminCliConfigPath(), { force: true });
22
+ }
23
+ test("loadAdminCliConfig throws a clear error when never logged in", () => {
24
+ cleanup();
25
+ try {
26
+ assert.throws(() => loadAdminCliConfig(), /Not logged in/);
27
+ }
28
+ finally {
29
+ cleanup();
30
+ }
31
+ });
32
+ // Regression test for the bug this cycle's fix closes: a present-but-
33
+ // malformed config.json (a hand-edit typo, disk-full/killed-mid-write, a
34
+ // cloud-synced homedir's conflicted copy) used to throw a raw JSON.parse
35
+ // SyntaxError instead of a clear, actionable message — every CLI command
36
+ // loads this file, so a corrupted one made the CLI permanently unusable
37
+ // with nothing but that unhelpful message to go on. Same regression shape
38
+ // packages/agent's own config.test.ts already established for the
39
+ // identical bug there.
40
+ test("loadAdminCliConfig throws a clear, actionable error (not a raw SyntaxError) for a malformed config file", () => {
41
+ cleanup();
42
+ mkdirSync(dirname(adminCliConfigPath()), { recursive: true });
43
+ writeFileSync(adminCliConfigPath(), "{ not valid json");
44
+ try {
45
+ assert.throws(() => loadAdminCliConfig(), (err) => {
46
+ assert.ok(err instanceof Error);
47
+ assert.ok(!(err instanceof SyntaxError), "expected a wrapped Error, not a raw JSON.parse SyntaxError");
48
+ assert.match(err.message, /corrupted/);
49
+ assert.match(err.message, /appforge-admin login/);
50
+ return true;
51
+ });
52
+ }
53
+ finally {
54
+ cleanup();
55
+ }
56
+ });
57
+ test("saveAdminCliConfig writes a file loadAdminCliConfig round-trips exactly", () => {
58
+ cleanup();
59
+ try {
60
+ const config = {
61
+ apiUrl: "https://api.example.test",
62
+ adminApiUrl: "https://admin-api.example.test",
63
+ token: "afk_test_token",
64
+ orgId: "org_1",
65
+ };
66
+ saveAdminCliConfig(config);
67
+ assert.deepEqual(loadAdminCliConfig(), config);
68
+ }
69
+ finally {
70
+ cleanup();
71
+ }
72
+ });
73
+ test("saveAdminCliConfig creates a fresh config file with 0600 permissions", () => {
74
+ cleanup();
75
+ try {
76
+ saveAdminCliConfig({
77
+ apiUrl: "https://api.example.test",
78
+ adminApiUrl: "https://admin-api.example.test",
79
+ token: "afk_test_token",
80
+ orgId: "org_1",
81
+ });
82
+ assert.equal(permBits(adminCliConfigPath()), 0o600);
83
+ }
84
+ finally {
85
+ cleanup();
86
+ }
87
+ });
88
+ test("saveAdminCliConfig produces a 0600 file even when overwriting a config that already existed with looser permissions", () => {
89
+ cleanup();
90
+ try {
91
+ // Simulates a file predating this protection (or loosened by hand, a
92
+ // backup restore, a cloud-synced homedir, ...). This assertion holds
93
+ // even with saveAdminCliConfig's own explicit chmodSync call deleted
94
+ // entirely — verified directly, see the comment on that call in
95
+ // config.ts — because renameSync() replaces the destination's
96
+ // directory entry with the freshly-created (and already-0600) temp
97
+ // file's inode wholesale, regardless of what permissions the
98
+ // destination had before. This test guards the actual end-state
99
+ // invariant (the file is 0600 after every save), not any one
100
+ // mechanism that happens to produce it, so it stays meaningful
101
+ // however that invariant ends up being achieved.
102
+ mkdirSync(dirname(adminCliConfigPath()), { recursive: true });
103
+ writeFileSync(adminCliConfigPath(), "{}", { mode: 0o644 });
104
+ assert.equal(permBits(adminCliConfigPath()), 0o644, "test fixture setup should start loose");
105
+ saveAdminCliConfig({
106
+ apiUrl: "https://api.example.test",
107
+ adminApiUrl: "https://admin-api.example.test",
108
+ token: "afk_test_token",
109
+ orgId: "org_1",
110
+ });
111
+ assert.equal(permBits(adminCliConfigPath()), 0o600);
112
+ }
113
+ finally {
114
+ cleanup();
115
+ }
116
+ });
117
+ test("adminCliConfigPath returns a real path under ~/.appforge-admin", () => {
118
+ assert.match(adminCliConfigPath(), /\.appforge-admin[/\\]config\.json$/);
119
+ });
120
+ //# sourceMappingURL=config.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.test.js","sourceRoot":"","sources":["../../src/lib/config.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ export declare function prompt(question: string): Promise<string>;
2
+ /**
3
+ * Reads a line from stdin without echoing it back to the terminal.
4
+ * Handles both raw-mode-TTY (one keystroke per `data` event) and piped
5
+ * input (the whole line arrives as one chunk) — see @appforge-ci/cli's copy
6
+ * of this file for the regression story; kept in sync manually since
7
+ * these two CLIs are deliberately independent packages, not a shared
8
+ * dependency (see this package's own module docblock in index.ts).
9
+ */
10
+ export declare function promptHidden(question: string): Promise<string>;
11
+ //# sourceMappingURL=prompt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../../src/lib/prompt.ts"],"names":[],"mappings":"AAEA,wBAAsB,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAO9D;AAKD;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA+BpE"}
@@ -0,0 +1,54 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ export async function prompt(question) {
3
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
4
+ try {
5
+ return await rl.question(question);
6
+ }
7
+ finally {
8
+ rl.close();
9
+ }
10
+ }
11
+ const CTRL_C = "\x03";
12
+ const BACKSPACE = ["\x08", "\x7f"];
13
+ /**
14
+ * Reads a line from stdin without echoing it back to the terminal.
15
+ * Handles both raw-mode-TTY (one keystroke per `data` event) and piped
16
+ * input (the whole line arrives as one chunk) — see @appforge-ci/cli's copy
17
+ * of this file for the regression story; kept in sync manually since
18
+ * these two CLIs are deliberately independent packages, not a shared
19
+ * dependency (see this package's own module docblock in index.ts).
20
+ */
21
+ export async function promptHidden(question) {
22
+ return new Promise((resolve) => {
23
+ process.stdout.write(question);
24
+ const stdin = process.stdin;
25
+ stdin.resume();
26
+ stdin.setRawMode?.(true);
27
+ let value = "";
28
+ const finish = () => {
29
+ stdin.setRawMode?.(false);
30
+ stdin.pause();
31
+ stdin.removeListener("data", onData);
32
+ process.stdout.write("\n");
33
+ resolve(value);
34
+ };
35
+ const onData = (buf) => {
36
+ const chunk = buf.toString("utf8");
37
+ if (chunk.includes(CTRL_C))
38
+ process.exit(1);
39
+ const newlineIndex = chunk.search(/[\n\r]/);
40
+ if (newlineIndex !== -1) {
41
+ value += chunk.slice(0, newlineIndex);
42
+ finish();
43
+ return;
44
+ }
45
+ if (BACKSPACE.includes(chunk)) {
46
+ value = value.slice(0, -1);
47
+ return;
48
+ }
49
+ value += chunk;
50
+ };
51
+ stdin.on("data", onData);
52
+ });
53
+ }
54
+ //# sourceMappingURL=prompt.js.map