@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,127 @@
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 { waitForCommandResolution, parseTimeoutSeconds, formatAgentCommandLine } from "./agents.js";
6
+ /**
7
+ * `parseTimeoutSeconds` guards `agents update`'s `--timeout` option
8
+ * before it ever reaches `waitForCommandResolution`'s deadline math: a
9
+ * non-numeric value (`Number("5m")` is `NaN`) would otherwise make the
10
+ * deadline `NaN`, and `Date.now() >= NaN` is always `false` — an infinite
11
+ * poll instead of a timeout.
12
+ */
13
+ test("parseTimeoutSeconds parses a normal integer string", () => {
14
+ assert.equal(parseTimeoutSeconds("600"), 600);
15
+ });
16
+ test("parseTimeoutSeconds returns null for a non-numeric value instead of NaN", () => {
17
+ assert.equal(parseTimeoutSeconds("5m"), null);
18
+ assert.equal(parseTimeoutSeconds("abc"), null);
19
+ });
20
+ test("parseTimeoutSeconds returns null for zero or a negative number", () => {
21
+ // A non-positive timeout would make waitForCommandResolution's deadline
22
+ // already-elapsed before the first poll even runs.
23
+ assert.equal(parseTimeoutSeconds("0"), null);
24
+ assert.equal(parseTimeoutSeconds("-10"), null);
25
+ });
26
+ test("parseTimeoutSeconds returns null for an empty string", () => {
27
+ assert.equal(parseTimeoutSeconds(""), null);
28
+ });
29
+ test("parseTimeoutSeconds accepts a decimal number of seconds", () => {
30
+ assert.equal(parseTimeoutSeconds("0.5"), 0.5);
31
+ });
32
+ /**
33
+ * `waitForCommandResolution` backs `appforge-admin agents update
34
+ * [--all]` (the OTA self-update rollout, `UPDATE_AGENT`) — real
35
+ * deadline/polling/terminal-state logic, not thin ApiClient passthrough
36
+ * like the rest of this file's command wiring, so it's the one function
37
+ * here worth a direct test. A fake `ApiClient` exposing just
38
+ * `listAgentCommands` (cast — the real class has plenty of other methods
39
+ * this function never calls) drives each scenario; `pollIntervalMs` is
40
+ * injected as 1ms so "it actually loops a few times" doesn't mean a real
41
+ * multi-second wait.
42
+ */
43
+ function fakeApi(pages) {
44
+ let call = 0;
45
+ return {
46
+ async listAgentCommands() {
47
+ const commands = pages[Math.min(call, pages.length - 1)];
48
+ call++;
49
+ return { commands };
50
+ },
51
+ };
52
+ }
53
+ function cmd(overrides = {}) {
54
+ return { id: "cmd-1", agentId: "agent-1", type: "UPDATE_AGENT", status: "pending", createdAt: "2026-01-01T00:00:00.000Z", ...overrides };
55
+ }
56
+ test("resolves immediately when the command is already done on the first poll", async () => {
57
+ const api = fakeApi([[cmd({ status: "done" })]]);
58
+ const result = await waitForCommandResolution(api, "agent-1", "cmd-1", 10_000, 1);
59
+ assert.equal(result?.status, "done");
60
+ });
61
+ test("treats a failed command as terminal too, not just done", async () => {
62
+ const api = fakeApi([[cmd({ status: "failed", result: "self-update failed: npm install exited 1" })]]);
63
+ const result = await waitForCommandResolution(api, "agent-1", "cmd-1", 10_000, 1);
64
+ assert.equal(result?.status, "failed");
65
+ assert.equal(result?.result, "self-update failed: npm install exited 1");
66
+ });
67
+ test("keeps polling while the command is pending/acked, then returns it once done", async () => {
68
+ const api = fakeApi([
69
+ [cmd({ status: "pending" })],
70
+ [cmd({ status: "acked" })],
71
+ [cmd({ status: "done" })],
72
+ ]);
73
+ const result = await waitForCommandResolution(api, "agent-1", "cmd-1", 10_000, 1);
74
+ assert.equal(result?.status, "done");
75
+ });
76
+ test("keeps polling when the command hasn't shown up in the page at all yet", async () => {
77
+ const api = fakeApi([
78
+ [], // agent hasn't acked the command yet — doesn't appear in its own history page
79
+ [cmd({ status: "done" })],
80
+ ]);
81
+ const result = await waitForCommandResolution(api, "agent-1", "cmd-1", 10_000, 1);
82
+ assert.equal(result?.status, "done");
83
+ });
84
+ test("returns null once timeoutMs elapses without the command ever resolving", async () => {
85
+ const api = fakeApi([[cmd({ status: "pending" })]]);
86
+ const result = await waitForCommandResolution(api, "agent-1", "cmd-1", 5, 1);
87
+ assert.equal(result, null);
88
+ });
89
+ /**
90
+ * Regression coverage for this cycle's fix: `agents commands <agentId>`
91
+ * is the admin-cli's new wrapper around `ApiClient.listAgentCommands`,
92
+ * which had a fully-implemented, already-tested route/DB layer
93
+ * (including `issuedByEmail`) but no CLI view at all —
94
+ * `docs/fleet-management.md` used to tell a reader to fall back to a
95
+ * raw `curl` for exactly this.
96
+ */
97
+ test("formatAgentCommandLine shows the issuing admin's email", () => {
98
+ const line = formatAgentCommandLine(cmd({ type: "DRAIN", status: "done", createdAt: "2026-01-02T00:00:00.000Z", issuedByEmail: "admin@example.com" }));
99
+ assert.match(line, /DRAIN/);
100
+ assert.match(line, /done/);
101
+ assert.match(line, /by:admin@example\.com/);
102
+ });
103
+ test("formatAgentCommandLine reports 'system/auto' for a null issuedByEmail", () => {
104
+ const line = formatAgentCommandLine(cmd({ issuedByEmail: null }));
105
+ assert.match(line, /by:system\/auto/);
106
+ });
107
+ test("formatAgentCommandLine appends the result string when present", () => {
108
+ const line = formatAgentCommandLine(cmd({ status: "failed", result: "self-update failed: npm install exited 1", issuedByEmail: "admin@example.com" }));
109
+ assert.match(line, /result:self-update failed: npm install exited 1/);
110
+ });
111
+ test("formatAgentCommandLine omits the result segment entirely when there's no result yet", () => {
112
+ const line = formatAgentCommandLine(cmd({ status: "pending", issuedByEmail: "admin@example.com" }));
113
+ assert.doesNotMatch(line, /result:/);
114
+ });
115
+ // OTA/self-update Phase 4, second slice (ROADMAP.md) — an auto-issued
116
+ // rollback UPDATE_AGENT must read as visibly different from an ordinary
117
+ // one in this same audit-trail view, not just inferrable from result text.
118
+ test("formatAgentCommandLine tags an auto-rollback UPDATE_AGENT distinctly", () => {
119
+ const line = formatAgentCommandLine(cmd({ type: "UPDATE_AGENT", isAutoRollback: 1, issuedByEmail: null }));
120
+ assert.match(line, /\[AUTO-ROLLBACK\]/);
121
+ });
122
+ test("formatAgentCommandLine tags nothing for an ordinary UPDATE_AGENT (isAutoRollback null/0/undefined)", () => {
123
+ assert.doesNotMatch(formatAgentCommandLine(cmd({ type: "UPDATE_AGENT" })), /AUTO-ROLLBACK/);
124
+ assert.doesNotMatch(formatAgentCommandLine(cmd({ type: "UPDATE_AGENT", isAutoRollback: 0 })), /AUTO-ROLLBACK/);
125
+ assert.doesNotMatch(formatAgentCommandLine(cmd({ type: "UPDATE_AGENT", isAutoRollback: null })), /AUTO-ROLLBACK/);
126
+ });
127
+ //# sourceMappingURL=agents.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agents.test.js","sourceRoot":"","sources":["../../src/commands/agents.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,38 @@
1
+ import { Command } from "commander";
2
+ import { ApiClient } from "@appforge-ci/core";
3
+ export type DeviceAuthPollOutcome = {
4
+ status: "approved";
5
+ token: string;
6
+ orgId: string;
7
+ orgName: string;
8
+ } | {
9
+ status: "denied";
10
+ } | {
11
+ status: "expired";
12
+ } | {
13
+ status: "timeout";
14
+ };
15
+ /**
16
+ * Pure polling loop, split out of `loginWeb` below so it's testable
17
+ * without a real terminal/browser/`process.exit` — same "extract the
18
+ * actual logic, leave I/O (and the process-exiting) at the call site"
19
+ * shape this run's other command extractions (`followBuildLogs`,
20
+ * `waitForCommandResolution`) already established. `sleepImpl` defaults
21
+ * to the real `sleep`; every real caller omits it.
22
+ */
23
+ export declare function pollDeviceAuthLogin(api: ApiClient, params: {
24
+ deviceCode: string;
25
+ expiresInSeconds: number;
26
+ intervalSeconds: number;
27
+ }, sleepImpl?: (ms: number) => Promise<unknown>): Promise<DeviceAuthPollOutcome>;
28
+ /**
29
+ * Auth still goes through apps/api (login/signup/API tokens live there,
30
+ * not on apps/admin-api) — this command just also requires --admin-api-url
31
+ * up front and only ever uses the resulting token against the admin
32
+ * Worker afterward. The account itself still needs to be on
33
+ * PLATFORM_ADMIN_EMAILS server-side; logging in here with a regular
34
+ * customer account works (auth succeeds) but every subsequent `agents`
35
+ * command will 403, same as calling the admin API any other way.
36
+ */
37
+ export declare function registerLoginCommand(program: Command): void;
38
+ //# sourceMappingURL=login.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../src/commands/login.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAe9C,MAAM,MAAM,qBAAqB,GAC7B;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACrE;IAAE,MAAM,EAAE,QAAQ,CAAA;CAAE,GACpB;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GACrB;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,CAAC;AAE1B;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,SAAS,EACd,MAAM,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,gBAAgB,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,MAAM,CAAA;CAAE,EACjF,SAAS,GAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAS,GAClD,OAAO,CAAC,qBAAqB,CAAC,CAWhC;AA6CD;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,QAuDpD"}
@@ -0,0 +1,128 @@
1
+ import { execFile } from "node:child_process";
2
+ import { ApiClient } from "@appforge-ci/core";
3
+ import { saveAdminCliConfig } from "../lib/config.js";
4
+ import { prompt, promptHidden } from "../lib/prompt.js";
5
+ import { loginWithTwoFactor } from "../lib/two-factor.js";
6
+ function sleep(ms) {
7
+ return new Promise((resolve) => setTimeout(resolve, ms));
8
+ }
9
+ /** Best-effort — over SSH or with no GUI this just fails silently and the
10
+ * user copies the printed URL by hand instead. */
11
+ function tryOpenBrowser(url) {
12
+ execFile("open", [url], () => { });
13
+ }
14
+ /**
15
+ * Pure polling loop, split out of `loginWeb` below so it's testable
16
+ * without a real terminal/browser/`process.exit` — same "extract the
17
+ * actual logic, leave I/O (and the process-exiting) at the call site"
18
+ * shape this run's other command extractions (`followBuildLogs`,
19
+ * `waitForCommandResolution`) already established. `sleepImpl` defaults
20
+ * to the real `sleep`; every real caller omits it.
21
+ */
22
+ export async function pollDeviceAuthLogin(api, params, sleepImpl = sleep) {
23
+ const deadline = Date.now() + params.expiresInSeconds * 1000;
24
+ for (;;) {
25
+ if (Date.now() > deadline)
26
+ return { status: "timeout" };
27
+ await sleepImpl(params.intervalSeconds * 1000);
28
+ const result = await api.pollDeviceAuth(params.deviceCode);
29
+ if (result.status === "denied")
30
+ return { status: "denied" };
31
+ if (result.status === "expired")
32
+ return { status: "expired" };
33
+ if (result.status !== "approved")
34
+ continue; // still pending
35
+ return { status: "approved", token: result.token, orgId: result.orgId, orgName: result.orgName };
36
+ }
37
+ }
38
+ /**
39
+ * Browser-based device-authorization flow (#5) — same shape as `gh auth
40
+ * login --web`: no password ever touches this terminal or its history.
41
+ * See apps/api/src/routes/auth.ts's /auth/device/* handlers for the
42
+ * server side of this handshake.
43
+ */
44
+ async function loginWeb(api, apiUrl, adminApiUrl) {
45
+ const { deviceCode, userCode, verificationUrl, expiresInSeconds, intervalSeconds } = await api.startDeviceAuth();
46
+ console.log(`First, confirm this code matches what's shown after you approve: ${userCode}\n`);
47
+ console.log(`Opening ${verificationUrl} in your browser...`);
48
+ console.log(`(if it doesn't open, visit that URL yourself and enter the code above)\n`);
49
+ tryOpenBrowser(verificationUrl);
50
+ const outcome = await pollDeviceAuthLogin(api, { deviceCode, expiresInSeconds, intervalSeconds });
51
+ if (outcome.status === "timeout") {
52
+ console.error("Login timed out waiting for approval — run 'appforge-admin login --web' again.");
53
+ process.exit(1);
54
+ }
55
+ if (outcome.status === "denied") {
56
+ console.error("Login was denied.");
57
+ process.exit(1);
58
+ }
59
+ if (outcome.status === "expired") {
60
+ console.error("That code expired before it was approved — run 'appforge-admin login --web' again.");
61
+ process.exit(1);
62
+ }
63
+ // approved — the dashboard's /auth/device/approve already minted the
64
+ // afk_ API token server-side, so there's no separate createApiToken
65
+ // call here like the password flow needs.
66
+ const authed = new ApiClient({ baseUrl: apiUrl, token: outcome.token });
67
+ const me = await authed.me();
68
+ if (!me.user.isPlatformAdmin) {
69
+ console.warn(`Warning: ${me.user.email} isn't on this instance's PLATFORM_ADMIN_EMAILS allowlist — ` +
70
+ `"agents" commands will 403 until it is.`);
71
+ }
72
+ saveAdminCliConfig({ apiUrl, adminApiUrl, token: outcome.token, orgId: outcome.orgId });
73
+ console.log(`Logged in as ${me.user.email} (org: ${outcome.orgName}).`);
74
+ }
75
+ /**
76
+ * Auth still goes through apps/api (login/signup/API tokens live there,
77
+ * not on apps/admin-api) — this command just also requires --admin-api-url
78
+ * up front and only ever uses the resulting token against the admin
79
+ * Worker afterward. The account itself still needs to be on
80
+ * PLATFORM_ADMIN_EMAILS server-side; logging in here with a regular
81
+ * customer account works (auth succeeds) but every subsequent `agents`
82
+ * command will 403, same as calling the admin API any other way.
83
+ */
84
+ export function registerLoginCommand(program) {
85
+ program
86
+ .command("login")
87
+ .description("Log in as a platform admin and store an API token in ~/.appforge-admin/config.json")
88
+ .requiredOption("--api-url <url>", "Customer-facing control-plane API URL (where auth lives)")
89
+ .requiredOption("--admin-api-url <url>", "Fleet-management API URL (apps/admin-api)")
90
+ .option("--email <email>")
91
+ .option("--web", "Log in via a browser instead of typing your password into this terminal")
92
+ .option("--totp-code <code>", "6-digit authenticator code, if 2FA is enabled on this account")
93
+ .option("--recovery-code <code>", "2FA recovery code, if you've lost your authenticator")
94
+ .action(async (opts) => {
95
+ const api = new ApiClient({ baseUrl: opts.apiUrl });
96
+ if (opts.web) {
97
+ await loginWeb(api, opts.apiUrl, opts.adminApiUrl);
98
+ return;
99
+ }
100
+ if (opts.totpCode && opts.recoveryCode) {
101
+ console.error("Pass either --totp-code or --recovery-code, not both.");
102
+ process.exit(1);
103
+ }
104
+ const email = opts.email ?? (await prompt("Email: "));
105
+ const password = await promptHidden("Password: ");
106
+ const { token, user } = await loginWithTwoFactor((twoFactor) => api.login(email, password, twoFactor), { totpCode: opts.totpCode, recoveryCode: opts.recoveryCode }, async () => {
107
+ const code = await prompt("Two-factor code (6 digits from your authenticator app), or leave blank to use a recovery code instead: ");
108
+ if (code.trim() !== "")
109
+ return { totpCode: code.trim() };
110
+ return { recoveryCode: (await prompt("Recovery code: ")).trim() };
111
+ });
112
+ const authed = new ApiClient({ baseUrl: opts.apiUrl, token });
113
+ const me = await authed.me();
114
+ const org = me.orgs[0];
115
+ if (!org) {
116
+ console.error("Your account has no orgs yet — sign up in the dashboard first.");
117
+ process.exit(1);
118
+ }
119
+ if (!user.isPlatformAdmin) {
120
+ console.warn(`Warning: ${user.email} isn't on this instance's PLATFORM_ADMIN_EMAILS allowlist — ` +
121
+ `"agents" commands will 403 until it is.`);
122
+ }
123
+ const { token: apiToken } = await authed.createApiToken(org.id, `admin-cli:${email}`);
124
+ saveAdminCliConfig({ apiUrl: opts.apiUrl, adminApiUrl: opts.adminApiUrl, token: apiToken, orgId: org.id });
125
+ console.log(`Logged in as ${user.email} (org: ${org.name}).`);
126
+ });
127
+ }
128
+ //# sourceMappingURL=login.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"login.js","sourceRoot":"","sources":["../../src/commands/login.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import "../test-support/home-sandbox.js";
2
+ //# sourceMappingURL=login.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"login.test.d.ts","sourceRoot":"","sources":["../../src/commands/login.test.ts"],"names":[],"mappings":"AACA,OAAO,iCAAiC,CAAC"}
@@ -0,0 +1,69 @@
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 { pollDeviceAuthLogin } from "./login.js";
6
+ function fakeApi(pages) {
7
+ let call = 0;
8
+ return {
9
+ async pollDeviceAuth() {
10
+ return pages[Math.min(call++, pages.length - 1)];
11
+ },
12
+ };
13
+ }
14
+ // A real (if near-zero) macrotask delay, not an instantly-resolving
15
+ // microtask — a mutation that turns a terminal check into an infinite
16
+ // loop needs the event loop's timer phase to actually run so node:test's
17
+ // own per-test `timeout` can fire and fail it cleanly, the same
18
+ // "an unbounded loop needs a real timer tick, not an assumption it'll
19
+ // terminate" lesson `build.test.ts` already learned this run.
20
+ function noopSleep() {
21
+ return new Promise((resolve) => setTimeout(resolve, 0));
22
+ }
23
+ test("resolves 'approved' immediately once the very first poll reports it", async () => {
24
+ const api = fakeApi([{ status: "approved", token: "afk_web_token", orgId: "org_1", orgName: "Acme" }]);
25
+ const outcome = await pollDeviceAuthLogin(api, { deviceCode: "dc1", expiresInSeconds: 600, intervalSeconds: 5 }, noopSleep);
26
+ assert.deepEqual(outcome, { status: "approved", token: "afk_web_token", orgId: "org_1", orgName: "Acme" });
27
+ });
28
+ test("keeps polling through 'pending' before eventually resolving 'approved'", { timeout: 5000 }, async () => {
29
+ const api = fakeApi([
30
+ { status: "pending" },
31
+ { status: "pending" },
32
+ { status: "approved", token: "afk_web_token", orgId: "org_1", orgName: "Acme" },
33
+ ]);
34
+ let calls = 0;
35
+ const outcome = await pollDeviceAuthLogin(api, { deviceCode: "dc1", expiresInSeconds: 600, intervalSeconds: 5 }, () => {
36
+ calls++;
37
+ return new Promise((resolve) => setTimeout(resolve, 0));
38
+ });
39
+ assert.equal(outcome.status, "approved");
40
+ assert.equal(calls, 3); // one sleep per poll, including the two pending ones
41
+ });
42
+ test("resolves 'denied' as soon as the server reports it, without waiting for a timeout", { timeout: 5000 }, async () => {
43
+ const api = fakeApi([{ status: "denied" }]);
44
+ const outcome = await pollDeviceAuthLogin(api, { deviceCode: "dc1", expiresInSeconds: 600, intervalSeconds: 5 }, noopSleep);
45
+ assert.deepEqual(outcome, { status: "denied" });
46
+ });
47
+ test("resolves 'expired' as soon as the server reports it", { timeout: 5000 }, async () => {
48
+ const api = fakeApi([{ status: "expired" }]);
49
+ const outcome = await pollDeviceAuthLogin(api, { deviceCode: "dc1", expiresInSeconds: 600, intervalSeconds: 5 }, noopSleep);
50
+ assert.deepEqual(outcome, { status: "expired" });
51
+ });
52
+ test("resolves 'timeout' once the deadline has passed, without ever polling again", { timeout: 5000 }, async () => {
53
+ let calls = 0;
54
+ const api = {
55
+ async pollDeviceAuth() {
56
+ calls++;
57
+ return { status: "pending" };
58
+ },
59
+ };
60
+ // A negative expiresInSeconds puts the deadline strictly in the past
61
+ // regardless of clock granularity, guaranteeing Date.now() > deadline
62
+ // is already true before the very first sleep/poll — the real
63
+ // regression this pins is the deadline check happening *before*
64
+ // polling starts, not just eventually.
65
+ const outcome = await pollDeviceAuthLogin(api, { deviceCode: "dc1", expiresInSeconds: -1, intervalSeconds: 5 }, noopSleep);
66
+ assert.deepEqual(outcome, { status: "timeout" });
67
+ assert.equal(calls, 0);
68
+ });
69
+ //# sourceMappingURL=login.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"login.test.js","sourceRoot":"","sources":["../../src/commands/login.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
package/dist/index.js ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { apiErrorMessage } from "@appforge-ci/core";
4
+ import { registerLoginCommand } from "./commands/login.js";
5
+ import { registerAgentsCommands } from "./commands/agents.js";
6
+ /**
7
+ * appforge-admin — fleet management for the AppForge platform operator.
8
+ *
9
+ * A deliberately separate tool from `appforge` (@appforge-ci/cli): that one
10
+ * is for AppForge's customers (login, apps, builds, billing) and never
11
+ * shows or touches fleet/agent commands at all, the same way a Codemagic
12
+ * customer has no equivalent of "manage Codemagic's own build machines."
13
+ * This one talks to apps/admin-api, a separately deployed Worker regular
14
+ * customers can't reach — see docs/self-hosting.md's "Fleet management"
15
+ * section for the full story of why these are two tools, not one with a
16
+ * role flag.
17
+ */
18
+ const program = new Command();
19
+ program
20
+ .name("appforge-admin")
21
+ .description("AppForge fleet management — enroll/list/drain/reboot Mac minis (platform admins only)")
22
+ .version("0.1.0");
23
+ registerLoginCommand(program);
24
+ registerAgentsCommands(program);
25
+ program.parseAsync(process.argv).catch((err) => {
26
+ // Preserves the original "print whatever was thrown" fallback for a
27
+ // non-Error throw (rare, but not this fix's concern) — only the
28
+ // Error/ApiError branch changes, to extract ApiError's own
29
+ // body.error instead of its raw `API error ${status}: {...}` message.
30
+ // eslint-disable-next-line no-console
31
+ console.error(err instanceof Error ? apiErrorMessage(err, err.message) : err);
32
+ process.exit(1);
33
+ });
34
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAE9D;;;;;;;;;;;GAWG;AACH,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;AAC9B,OAAO;KACJ,IAAI,CAAC,gBAAgB,CAAC;KACtB,WAAW,CAAC,uFAAuF,CAAC;KACpG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEpB,oBAAoB,CAAC,OAAO,CAAC,CAAC;AAC9B,sBAAsB,CAAC,OAAO,CAAC,CAAC;AAEhC,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IAC7C,oEAAoE;IACpE,gEAAgE;IAChE,2DAA2D;IAC3D,sEAAsE;IACtE,sCAAsC;IACtC,OAAO,CAAC,KAAK,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC,GAAG,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;IAC9E,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Appends a PATH block once, idempotently — re-running `agents bootstrap`
3
+ * (e.g. after installing a toolchain that was missing the first time)
4
+ * shouldn't pile up duplicate exports on every run.
5
+ *
6
+ * Exported for bootstrap.test.ts — every other caller in this file only
7
+ * ever uses it as an internal helper, but its idempotency and
8
+ * existing-content-preservation are worth testing directly rather than
9
+ * only indirectly through bootstrapSafeToolchains(), which can't be
10
+ * exercised at all in a headless environment (it shells out to a real
11
+ * `brew`/`sdkmanager`/Homebrew toolchain install).
12
+ */
13
+ export declare function ensurePathBlock(profilePath: string, lines: string[]): void;
14
+ export declare function bootstrapSafeToolchains(): Promise<void>;
15
+ /**
16
+ * The system/security-sensitive half of bootstrap.sh — never run unless
17
+ * explicitly requested via `--full`. Appropriate for a dedicated always-on
18
+ * Mac mini; not something to do to a machine you use day to day.
19
+ */
20
+ export declare function bootstrapPrivileged(): void;
21
+ //# sourceMappingURL=bootstrap.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bootstrap.d.ts","sourceRoot":"","sources":["../../src/lib/bootstrap.ts"],"names":[],"mappings":"AAyDA;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAS1E;AA2DD,wBAAsB,uBAAuB,IAAI,OAAO,CAAC,IAAI,CAAC,CA0E7D;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,IAAI,IAAI,CAiC1C"}