@nurix/apollo 0.2.0 → 0.3.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.
- package/README.md +5 -1
- package/dist/commands/init.js +17 -0
- package/dist/commands/login.js +90 -0
- package/dist/commands/open.js +1 -10
- package/dist/commands/sync.js +4 -0
- package/dist/index.js +7 -1
- package/dist/lib/browser_opener.js +17 -0
- package/dist/lib/device_flow.js +60 -0
- package/dist/lib/device_identity.js +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,11 @@ npm install -g @nurix/apollo # or install the `apollo` binary globally
|
|
|
23
23
|
## Commands (implemented)
|
|
24
24
|
|
|
25
25
|
```
|
|
26
|
-
apollo
|
|
26
|
+
apollo login [--no-open] # browser SSO via 24h device trust: approve once at
|
|
27
|
+
# /device (Google SSO / OTP); re-logins within the
|
|
28
|
+
# window need no browser. Issues APOLLO_APP_KEY +
|
|
29
|
+
# APOLLO_APP_ID into the managed block.
|
|
30
|
+
apollo init # login (or paste a key), gitignore .env,
|
|
27
31
|
# scaffold apollo.service.json
|
|
28
32
|
apollo add <service> # the installer (see flow below)
|
|
29
33
|
apollo add # same, picking the service from the catalogue
|
package/dist/commands/init.js
CHANGED
|
@@ -7,6 +7,7 @@ import { resolveApolloUrl, validateAppKey } from "../lib/apollo_client.js";
|
|
|
7
7
|
import { ensureEnvGitignored, readManagedValues, upsertManagedValues } from "../lib/env_file.js";
|
|
8
8
|
import { hasManifest, scaffoldManifest } from "../lib/manifest.js";
|
|
9
9
|
import { isInteractive } from "../lib/interactive.js";
|
|
10
|
+
import { performDeviceLogin } from "./login.js";
|
|
10
11
|
// Run the init flow: collect + validate the key, write managed blocks, scaffold.
|
|
11
12
|
export async function runInit(options) {
|
|
12
13
|
const cwd = process.cwd();
|
|
@@ -28,6 +29,18 @@ export async function runInit(options) {
|
|
|
28
29
|
return;
|
|
29
30
|
}
|
|
30
31
|
}
|
|
32
|
+
const viaBrowser = await p.confirm({
|
|
33
|
+
message: "Sign in via the browser to create your application & key? (No = paste a key)",
|
|
34
|
+
});
|
|
35
|
+
if (!p.isCancel(viaBrowser) && viaBrowser) {
|
|
36
|
+
if (!(await performDeviceLogin(baseUrl, cwd))) {
|
|
37
|
+
p.outro("Sign-in failed — re-run `apollo init`, or pick the paste option.");
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
await finishInit(cwd);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
31
44
|
const appKey = await p.password({
|
|
32
45
|
message: `Paste your APOLLO_APP_KEY (create an application in the Apollo console: ${baseUrl})`,
|
|
33
46
|
validate: (value) => value?.startsWith("apollo_app_") ? undefined : "Expected a key starting with apollo_app_",
|
|
@@ -51,6 +64,10 @@ export async function runInit(options) {
|
|
|
51
64
|
if (await ensureEnvGitignored(cwd)) {
|
|
52
65
|
p.log.info("Added .env to .gitignore");
|
|
53
66
|
}
|
|
67
|
+
await finishInit(cwd);
|
|
68
|
+
}
|
|
69
|
+
// Shared init tail: offer the manifest scaffold, then close out.
|
|
70
|
+
async function finishInit(cwd) {
|
|
54
71
|
if (!hasManifest(cwd)) {
|
|
55
72
|
const repoName = deriveRepoName(cwd);
|
|
56
73
|
const shouldScaffold = await p.confirm({
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// packages/cli/src/commands/login.ts - `apollo login`: browser SSO via Apollo's
|
|
2
|
+
// 24h device-trust flow (consumer-journey Stage 1). A device approved within
|
|
3
|
+
// the window re-issues keys with no browser round-trip; only a fresh / expired
|
|
4
|
+
// device needs the approval leg. Writes APOLLO_APP_KEY + APOLLO_APP_ID into
|
|
5
|
+
// the managed .env block — no key pasting.
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import * as p from "@clack/prompts";
|
|
8
|
+
import { resolveApolloUrl } from "../lib/apollo_client.js";
|
|
9
|
+
import { ensureEnvGitignored, readManagedValues, upsertManagedValues } from "../lib/env_file.js";
|
|
10
|
+
import { launchBrowser } from "../lib/browser_opener.js";
|
|
11
|
+
import { getOrCreateDeviceId } from "../lib/device_identity.js";
|
|
12
|
+
import { pollDeviceOnce, pollUntilIssued, startDeviceTrust, } from "../lib/device_flow.js";
|
|
13
|
+
// Browser-approval wait cap; the trust window itself is 24h server-side.
|
|
14
|
+
const APPROVAL_TIMEOUT_SECONDS = 10 * 60;
|
|
15
|
+
// Run the login flow and write the issued key; returns false on failure.
|
|
16
|
+
export async function performDeviceLogin(baseUrl, cwd, flags = {}) {
|
|
17
|
+
const deviceId = await getOrCreateDeviceId();
|
|
18
|
+
const start = await startDeviceTrust(baseUrl, deviceId);
|
|
19
|
+
const managedValues = await readManagedValues(path.join(cwd, ".env"));
|
|
20
|
+
const pollParams = {
|
|
21
|
+
deviceCode: start.device_code,
|
|
22
|
+
deviceId,
|
|
23
|
+
appName: deriveAppName(cwd),
|
|
24
|
+
applicationId: managedValues.APOLLO_APP_ID || undefined,
|
|
25
|
+
};
|
|
26
|
+
// Trusted-device fast path: an approval inside the 24h window is not
|
|
27
|
+
// consumed, so a single poll may already succeed with no browser at all.
|
|
28
|
+
let issued = null;
|
|
29
|
+
const firstAttempt = await pollDeviceOnce(baseUrl, pollParams);
|
|
30
|
+
if (firstAttempt.kind === "issued") {
|
|
31
|
+
issued = firstAttempt.issued;
|
|
32
|
+
p.log.success("Device already trusted — no browser approval needed");
|
|
33
|
+
}
|
|
34
|
+
else if (firstAttempt.kind === "failed") {
|
|
35
|
+
p.log.error(firstAttempt.message);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (!issued) {
|
|
39
|
+
const verificationUrl = start.verification_uri_complete ?? start.verification_uri;
|
|
40
|
+
p.note(`Code: ${start.user_code}\n\nApprove at: ${verificationUrl}\n(valid for ~${Math.round(start.expires_in / 3600)}h on this device)`, "Confirm this code in your browser");
|
|
41
|
+
if (flags.open !== false)
|
|
42
|
+
launchBrowser(verificationUrl);
|
|
43
|
+
const spinner = p.spinner();
|
|
44
|
+
spinner.start("Waiting for approval in the browser");
|
|
45
|
+
try {
|
|
46
|
+
issued = await pollUntilIssued(baseUrl, pollParams, start.interval, Math.min(start.expires_in, APPROVAL_TIMEOUT_SECONDS));
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
spinner.stop("Sign-in failed", 1);
|
|
50
|
+
p.log.error(error instanceof Error ? error.message : String(error));
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
spinner.stop("Approved");
|
|
54
|
+
}
|
|
55
|
+
p.log.success(`Key issued for application '${issued.name}' (${issued.environment})`);
|
|
56
|
+
await upsertManagedValues(path.join(cwd, ".env"), {
|
|
57
|
+
APOLLO_APP_KEY: issued.token,
|
|
58
|
+
APOLLO_APP_ID: issued.appId,
|
|
59
|
+
});
|
|
60
|
+
await upsertManagedValues(path.join(cwd, ".env.example"), {
|
|
61
|
+
APOLLO_APP_KEY: "",
|
|
62
|
+
APOLLO_APP_ID: "",
|
|
63
|
+
});
|
|
64
|
+
if (await ensureEnvGitignored(cwd)) {
|
|
65
|
+
p.log.info("Added .env to .gitignore");
|
|
66
|
+
}
|
|
67
|
+
p.log.success("APOLLO_APP_KEY + APOLLO_APP_ID written to the managed .env block");
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
// `apollo login` — the standalone command wrapper.
|
|
71
|
+
export async function runLogin(options, flags = {}) {
|
|
72
|
+
const cwd = process.cwd();
|
|
73
|
+
const baseUrl = resolveApolloUrl(options);
|
|
74
|
+
p.intro("apollo login");
|
|
75
|
+
const didLogin = await performDeviceLogin(baseUrl, cwd, flags);
|
|
76
|
+
if (!didLogin) {
|
|
77
|
+
p.outro("Nothing was written.");
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
p.outro("Ready — run `apollo add <service>` to wire in stack services.");
|
|
82
|
+
}
|
|
83
|
+
// Derive a spec-conformant application name from the folder name.
|
|
84
|
+
function deriveAppName(cwd) {
|
|
85
|
+
return (path
|
|
86
|
+
.basename(cwd)
|
|
87
|
+
.toLowerCase()
|
|
88
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
89
|
+
.replace(/^-+|-+$/g, "") || "app");
|
|
90
|
+
}
|
package/dist/commands/open.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// packages/cli/src/commands/open.ts - `apollo open <service>`: open the
|
|
2
2
|
// service's endpoint (or repository) in the default browser.
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
4
3
|
import * as p from "@clack/prompts";
|
|
5
4
|
import { fetchCatalogue, resolveApolloUrl } from "../lib/apollo_client.js";
|
|
5
|
+
import { launchBrowser } from "../lib/browser_opener.js";
|
|
6
6
|
// Resolve the URL to open (chosen endpoint > production > any > repository) and launch it.
|
|
7
7
|
export async function runOpen(serviceName, options, globals) {
|
|
8
8
|
const baseUrl = resolveApolloUrl(globals);
|
|
@@ -26,12 +26,3 @@ export async function runOpen(serviceName, options, globals) {
|
|
|
26
26
|
launchBrowser(url);
|
|
27
27
|
p.log.success(`Opening ${url}`);
|
|
28
28
|
}
|
|
29
|
-
// Launch the platform's default opener, detached so the CLI can exit.
|
|
30
|
-
function launchBrowser(url) {
|
|
31
|
-
const [command, args] = process.platform === "darwin"
|
|
32
|
-
? ["open", [url]]
|
|
33
|
-
: process.platform === "win32"
|
|
34
|
-
? ["cmd", ["/c", "start", "", url]]
|
|
35
|
-
: ["xdg-open", [url]];
|
|
36
|
-
spawn(command, args, { detached: true, stdio: "ignore" }).unref();
|
|
37
|
-
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -35,6 +35,10 @@ export async function runSync(options) {
|
|
|
35
35
|
spinner.stop(`Application '${projection.app.name}' (${projection.app.environment}): ${projection.credentials.length} credential(s)`);
|
|
36
36
|
const nextValues = { APOLLO_APP_KEY: appKey };
|
|
37
37
|
const placeholders = { APOLLO_APP_KEY: "" };
|
|
38
|
+
if (managedValues.APOLLO_APP_ID) {
|
|
39
|
+
nextValues.APOLLO_APP_ID = managedValues.APOLLO_APP_ID;
|
|
40
|
+
placeholders.APOLLO_APP_ID = "";
|
|
41
|
+
}
|
|
38
42
|
for (const credential of [...projection.credentials].sort((a, b) => a.service.localeCompare(b.service))) {
|
|
39
43
|
const prefix = toEnvPrefix(credential.service);
|
|
40
44
|
nextValues[`${prefix}_ENDPOINT`] = credential.endpoint;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// packages/cli/src/index.ts - `apollo` CLI entry: command wiring.
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
+
import { runLogin } from "./commands/login.js";
|
|
4
5
|
import { runInit } from "./commands/init.js";
|
|
5
6
|
import { runAdd } from "./commands/add.js";
|
|
6
7
|
import { runList } from "./commands/list.js";
|
|
@@ -14,8 +15,13 @@ const program = new Command();
|
|
|
14
15
|
program
|
|
15
16
|
.name("apollo")
|
|
16
17
|
.description("Bootstrap NuStack services into your repo via the Apollo discovery plane.")
|
|
17
|
-
.version("0.
|
|
18
|
+
.version("0.3.0")
|
|
18
19
|
.option("--apollo-url <url>", "Apollo base URL (defaults to $APOLLO_URL, then the hosted instance)");
|
|
20
|
+
program
|
|
21
|
+
.command("login")
|
|
22
|
+
.description("Sign in via the browser (24h device trust) and issue your APOLLO_APP_KEY")
|
|
23
|
+
.option("--no-open", "don't auto-open the browser; just print the approval URL")
|
|
24
|
+
.action(async (options) => runLogin(program.opts(), options));
|
|
19
25
|
program
|
|
20
26
|
.command("init")
|
|
21
27
|
.description("Store your APOLLO_APP_KEY in the managed .env block and scaffold the manifest")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// packages/cli/src/lib/browser_opener.ts - Open a URL in the platform's
|
|
2
|
+
// default browser, detached so the CLI can keep running.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
// Launch the platform's default opener; never throws (callers print the URL anyway).
|
|
5
|
+
export function launchBrowser(url) {
|
|
6
|
+
const [command, args] = process.platform === "darwin"
|
|
7
|
+
? ["open", [url]]
|
|
8
|
+
: process.platform === "win32"
|
|
9
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
10
|
+
: ["xdg-open", [url]];
|
|
11
|
+
try {
|
|
12
|
+
spawn(command, args, { detached: true, stdio: "ignore" }).unref();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// the printed URL is the fallback
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// packages/cli/src/lib/device_flow.ts - Apollo's 24h device-trust flow. The
|
|
2
|
+
// CLI never talks to Google: it registers its device id with Apollo, the human
|
|
3
|
+
// approves the device's code at /device in the browser (where Apollo's own
|
|
4
|
+
// Google SSO runs), and the CLI redeems the approval for an APOLLO_APP_KEY.
|
|
5
|
+
// The same code is reused for the same device within the 24h window, and an
|
|
6
|
+
// approval is not consumed by redemption — re-login inside the window needs
|
|
7
|
+
// no browser round-trip.
|
|
8
|
+
// Register (or re-fetch) this device's trust code.
|
|
9
|
+
export async function startDeviceTrust(baseUrl, deviceId) {
|
|
10
|
+
const response = await fetch(`${baseUrl}/api/v1/device/start`, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: { "Content-Type": "application/json" },
|
|
13
|
+
body: JSON.stringify({ device_id: deviceId }),
|
|
14
|
+
signal: AbortSignal.timeout(10_000),
|
|
15
|
+
});
|
|
16
|
+
const payload = (await response.json().catch(() => null));
|
|
17
|
+
if (!response.ok || !payload?.device_code || !payload?.user_code) {
|
|
18
|
+
throw new Error(payload?.message ?? `device start failed: HTTP ${response.status}`);
|
|
19
|
+
}
|
|
20
|
+
return payload;
|
|
21
|
+
}
|
|
22
|
+
// One redemption attempt — used directly for the trusted-device fast path.
|
|
23
|
+
export async function pollDeviceOnce(baseUrl, params) {
|
|
24
|
+
const response = await fetch(`${baseUrl}/api/v1/device/poll`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
device_code: params.deviceCode,
|
|
29
|
+
device_id: params.deviceId,
|
|
30
|
+
name: params.appName,
|
|
31
|
+
applicationId: params.applicationId,
|
|
32
|
+
}),
|
|
33
|
+
signal: AbortSignal.timeout(10_000),
|
|
34
|
+
});
|
|
35
|
+
const payload = (await response.json().catch(() => null));
|
|
36
|
+
if (response.ok && payload?.token && payload?.appId) {
|
|
37
|
+
return { kind: "issued", issued: payload };
|
|
38
|
+
}
|
|
39
|
+
if (payload?.error === "authorization_pending") {
|
|
40
|
+
return { kind: "pending" };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
kind: "failed",
|
|
44
|
+
message: payload?.message ?? `device poll failed: HTTP ${response.status}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// Poll until approved in the browser, denied, or the window expires.
|
|
48
|
+
export async function pollUntilIssued(baseUrl, params, intervalSeconds, timeoutSeconds) {
|
|
49
|
+
const deadline = Date.now() + timeoutSeconds * 1_000;
|
|
50
|
+
const waitMs = Math.max(intervalSeconds, 1) * 1_000;
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
53
|
+
const outcome = await pollDeviceOnce(baseUrl, params);
|
|
54
|
+
if (outcome.kind === "issued")
|
|
55
|
+
return outcome.issued;
|
|
56
|
+
if (outcome.kind === "failed")
|
|
57
|
+
throw new Error(outcome.message);
|
|
58
|
+
}
|
|
59
|
+
throw new Error("timed out waiting for browser approval — run `apollo login` again");
|
|
60
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// packages/cli/src/lib/device_identity.ts - Stable per-machine device identity
|
|
2
|
+
// for the 24h device-trust window. Generated once, persisted at
|
|
3
|
+
// ~/.apollo/device_id, and sent with every device/start + device/poll call.
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
// Read the persisted device id, creating one on first use.
|
|
10
|
+
export async function getOrCreateDeviceId() {
|
|
11
|
+
const dir = path.join(os.homedir(), ".apollo");
|
|
12
|
+
const file = path.join(dir, "device_id");
|
|
13
|
+
if (existsSync(file)) {
|
|
14
|
+
const existing = (await readFile(file, "utf8")).trim();
|
|
15
|
+
if (existing)
|
|
16
|
+
return existing;
|
|
17
|
+
}
|
|
18
|
+
const deviceId = `dev_${randomBytes(16).toString("hex")}`;
|
|
19
|
+
await mkdir(dir, { recursive: true });
|
|
20
|
+
await writeFile(file, `${deviceId}\n`, "utf8");
|
|
21
|
+
return deviceId;
|
|
22
|
+
}
|