@nurix/apollo 0.2.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/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # `@nurix/apollo` — the `apollo` CLI
2
+
3
+ The consumer-side installer for the Apollo discovery plane. It implements the
4
+ build-time integration flow of the
5
+ [consumer journey](../../docs/discovery/consumer-journey.md): exchange your
6
+ application key for service-scoped credentials, write them into a managed
7
+ `.env` block, install the service's npm package, and record the `consumes`
8
+ edge in your `apollo.service.json` (the application's bill of materials).
9
+
10
+ Interactive UX is built on [`@clack/prompts`](https://github.com/bombshell-dev/clack).
11
+
12
+ ## Install
13
+
14
+ Published to npm with **restricted access** under the `@nurix` scope — you need
15
+ an npm account with read rights to the `nurix` org (`npm login`, then verify
16
+ with `npm whoami`).
17
+
18
+ ```
19
+ npx @nurix/apollo@latest --help # one-shot
20
+ npm install -g @nurix/apollo # or install the `apollo` binary globally
21
+ ```
22
+
23
+ ## Commands (implemented)
24
+
25
+ ```
26
+ apollo init # store + validate APOLLO_APP_KEY, gitignore .env,
27
+ # scaffold apollo.service.json
28
+ apollo add <service> # the installer (see flow below)
29
+ apollo add # same, picking the service from the catalogue
30
+ apollo list # one line per catalogue service
31
+
32
+ apollo sync # re-project ALL credentials issued to this
33
+ # application into the managed block (server wins;
34
+ # stale entries removed)
35
+ apollo describe <svc> [--json] # full catalogue entry, pretty or raw
36
+ apollo validate [manifest] [--schema <pathOrUrl>]
37
+ # JSON Schema validation (apollo.dev/v1, draft-07;
38
+ # schema bundled into dist/ at build time)
39
+ apollo graph [--mermaid] # consumes-graph: ascii edges or mermaid `graph LR`
40
+ apollo doctor # key validity + per-credential health probes +
41
+ # manifest conformance + deprecated-consumes warnings
42
+ apollo open <svc> [--env <name>] # open endpoint (default production) or repo URL
43
+ ```
44
+
45
+ Global options: `--apollo-url <url>` (defaults to `$APOLLO_URL`, then the
46
+ hosted instance `https://apollo.nurix-ai.co`).
47
+
48
+ ### `apollo init`
49
+
50
+ 1. Prompts for your `APOLLO_APP_KEY` (created with your application in the
51
+ Apollo console) and **validates it against Apollo — no key, no install**.
52
+ 2. Writes it into the **Apollo-managed block** of `.env` (+ a placeholder in
53
+ `.env.example`) and makes sure `.env` is gitignored.
54
+ 3. Offers to scaffold `apollo.service.json` if the repo has none.
55
+
56
+ ### `apollo add <service>` — the installer
57
+
58
+ 1. Reads `APOLLO_APP_KEY` from the managed block (or the environment); fails
59
+ loudly with remediation if missing.
60
+ 2. Fetches the catalogue and resolves the service (interactive select when no
61
+ name is passed).
62
+ 3. `POST /api/v1/keys/exchange` → `{ endpoint, serviceKey }` — idempotent per
63
+ (application × service), so re-running is safe.
64
+ 4. Writes `<SERVICE>_ENDPOINT` + `<SERVICE>_KEY` into the managed `.env`
65
+ block, placeholders into `.env.example`.
66
+ 5. Installs the service's npm package when it ships one (package manager
67
+ detected from the repo's lockfile).
68
+ 6. Appends the `consumes` edge to `apollo.service.json` (offers a scaffold if
69
+ the manifest is missing).
70
+ 7. Best-effort health probe against the issued endpoint (never blocks).
71
+
72
+ ### The managed `.env` block
73
+
74
+ The CLI owns **only** the delimited block — everything outside it is yours and
75
+ is never touched:
76
+
77
+ ```
78
+ MY_OWN_SECRET=untouched
79
+
80
+ # >>> apollo:managed — do not edit by hand; the apollo CLI rewrites this block >>>
81
+ APOLLO_APP_KEY=apollo_app_…
82
+ EMAIL_ENDPOINT=https://…
83
+ EMAIL_KEY=apollo_svc_…
84
+ # <<< apollo:managed <<<
85
+ ```
86
+
87
+ Inside the block the server is the source of truth (manual edits get
88
+ overwritten); outside it, nothing is ever rewritten.
89
+
90
+ ## Development
91
+
92
+ ```
93
+ pnpm --filter @nurix/apollo build # tsc → dist/ (+ schema copy)
94
+ pnpm --filter @nurix/apollo typecheck
95
+ node packages/cli/dist/index.js --help # run from the repo root
96
+ ```
97
+
98
+ ESM, Node ≥ 22, strict TypeScript. `bin` is `dist/index.js` (`apollo`).
99
+ Releases ship via `.github/workflows/publish-cli-to-npm.yml` — merges to `dev`
100
+ touching `packages/cli/**` auto-publish a patch; dispatch the workflow for
101
+ minor/major.
102
+
103
+ ## Current limitations
104
+
105
+ - The hosted catalogue is empty until the registry read path lands
106
+ (consumer-journey.md R3) — `list`/`describe`/`graph`/`open` degrade with a
107
+ clear message; `apollo add <name>` still works for services seeded into the
108
+ KV catalogue, since the exchange resolves endpoints server-side.
109
+ - `apollo sync` needs `GET /api/v1/keys/credentials` (journey R7) — the route
110
+ is in the worker code; it goes live with the next worker deploy.
111
+ - Exchange still returns `{endpoint, serviceKey}` together; once it slims to
112
+ credential-only with Apollo-served definitions (journey §9.2–§9.3), the
113
+ client picks the endpoint up from the definition instead
114
+ (`src/lib/apollo_client.ts` is the seam).
@@ -0,0 +1,152 @@
1
+ // packages/cli/src/commands/add.ts - `apollo add <service>`: the installer.
2
+ // Resolves the service in the catalogue, exchanges the app key for a scoped
3
+ // credential, writes the managed .env block, installs the npm package, and
4
+ // records the consumes edge (consumer-journey.md §4–§5).
5
+ import path from "node:path";
6
+ import * as p from "@clack/prompts";
7
+ import { exchangeServiceCredential, fetchCatalogue, resolveApolloUrl, } from "../lib/apollo_client.js";
8
+ import { readManagedValues, upsertManagedValues } from "../lib/env_file.js";
9
+ import { detectPackageManager, installPackage } from "../lib/package_manager.js";
10
+ import { appendConsumesEdge, hasManifest, scaffoldManifest } from "../lib/manifest.js";
11
+ import { confirmOrFallback, isInteractive } from "../lib/interactive.js";
12
+ // Run the installer flow end to end for one service.
13
+ export async function runAdd(serviceArg, options) {
14
+ const cwd = process.cwd();
15
+ const baseUrl = resolveApolloUrl(options);
16
+ p.intro(`apollo add${serviceArg ? ` ${serviceArg}` : ""}`);
17
+ const managedValues = await readManagedValues(path.join(cwd, ".env"));
18
+ const appKey = managedValues.APOLLO_APP_KEY ?? process.env.APOLLO_APP_KEY;
19
+ if (!appKey) {
20
+ p.log.error("No APOLLO_APP_KEY found (managed .env block or environment).");
21
+ p.outro("Run `apollo init` first — no key, no install.");
22
+ process.exitCode = 1;
23
+ return;
24
+ }
25
+ const spinner = p.spinner();
26
+ spinner.start("Fetching the catalogue");
27
+ const catalogue = await fetchCatalogue(baseUrl).catch(() => null);
28
+ const services = catalogue?.services ?? [];
29
+ spinner.stop(`Catalogue loaded: ${services.length} service(s)`);
30
+ const serviceName = serviceArg ?? (await pickService(services));
31
+ if (!serviceName) {
32
+ p.outro("Nothing was written.");
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+ const catalogueEntry = services.find((service) => service.name === serviceName);
37
+ if (!catalogueEntry) {
38
+ p.log.warn(`'${serviceName}' is not in the catalogue — attempting the exchange anyway.`);
39
+ }
40
+ spinner.start(`Exchanging credentials for '${serviceName}'`);
41
+ let credential;
42
+ try {
43
+ credential = await exchangeServiceCredential(baseUrl, appKey, serviceName);
44
+ }
45
+ catch (error) {
46
+ spinner.stop("Exchange failed", 1);
47
+ p.log.error(error instanceof Error ? error.message : String(error));
48
+ p.outro("Nothing was written.");
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+ spinner.stop("Credential issued (idempotent per application + service)");
53
+ const envPrefix = toEnvPrefix(serviceName);
54
+ await upsertManagedValues(path.join(cwd, ".env"), {
55
+ [`${envPrefix}_ENDPOINT`]: credential.endpoint,
56
+ [`${envPrefix}_KEY`]: credential.serviceKey,
57
+ });
58
+ await upsertManagedValues(path.join(cwd, ".env.example"), {
59
+ [`${envPrefix}_ENDPOINT`]: "",
60
+ [`${envPrefix}_KEY`]: "",
61
+ });
62
+ p.log.success(`Wrote ${envPrefix}_ENDPOINT and ${envPrefix}_KEY to the managed .env block`);
63
+ await maybeInstallPackage(catalogueEntry, cwd);
64
+ await recordConsumesEdge(catalogueEntry, serviceName, cwd);
65
+ await probeHealth(catalogueEntry, credential);
66
+ p.outro(`'${serviceName}' is wired in.`);
67
+ }
68
+ // Let the user pick a service interactively when none was passed.
69
+ async function pickService(services) {
70
+ if (services.length === 0) {
71
+ p.log.error("The catalogue is empty — pass the service name explicitly: apollo add <service>");
72
+ return undefined;
73
+ }
74
+ if (!isInteractive()) {
75
+ p.log.error("No terminal for the picker — pass the service name explicitly: apollo add <service>");
76
+ return undefined;
77
+ }
78
+ const selection = await p.select({
79
+ message: "Which service do you want to add?",
80
+ options: services.map((service) => ({
81
+ value: service.name,
82
+ label: service.name,
83
+ hint: [service.type ?? service.kind, service.status].filter(Boolean).join(" · "),
84
+ })),
85
+ });
86
+ if (p.isCancel(selection)) {
87
+ p.cancel("add cancelled.");
88
+ return undefined;
89
+ }
90
+ return selection;
91
+ }
92
+ // Derive the env-var prefix from the service name (nustack-rag → NUSTACK_RAG).
93
+ function toEnvPrefix(serviceName) {
94
+ return serviceName.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
95
+ }
96
+ // Install the service's npm package when it ships one, after confirmation.
97
+ async function maybeInstallPackage(entry, cwd) {
98
+ const packageName = entry?.npm?.package;
99
+ if (!packageName)
100
+ return;
101
+ const manager = detectPackageManager(cwd);
102
+ const shouldInstall = await confirmOrFallback(`Install ${packageName} with ${manager}?`, true);
103
+ if (!shouldInstall) {
104
+ p.log.info(`Skipped the package install — run: ${manager} add ${packageName}`);
105
+ return;
106
+ }
107
+ try {
108
+ await installPackage(manager, packageName, cwd);
109
+ p.log.success(`Installed ${packageName}`);
110
+ }
111
+ catch (error) {
112
+ p.log.warn(`Package install failed (non-blocking): ${error instanceof Error ? error.message : error}`);
113
+ }
114
+ }
115
+ // Record the consumes edge in apollo.service.json, offering a scaffold if absent.
116
+ async function recordConsumesEdge(entry, serviceName, cwd) {
117
+ if (!hasManifest(cwd)) {
118
+ const repoName = path.basename(cwd).toLowerCase().replace(/[^a-z0-9-]+/g, "-");
119
+ const shouldScaffold = await confirmOrFallback(`No apollo.service.json — scaffold one for "${repoName}" to record the consumes edge?`, true);
120
+ if (!shouldScaffold) {
121
+ p.log.info("Skipped — the consumes edge (bill of materials) was not recorded.");
122
+ return;
123
+ }
124
+ await scaffoldManifest(cwd, repoName);
125
+ p.log.success("Scaffolded apollo.service.json");
126
+ }
127
+ const via = entry?.npm?.package ? "npm" : "http";
128
+ const wasAppended = await appendConsumesEdge(cwd, { service: serviceName, via, endpoint: "production" });
129
+ p.log.success(wasAppended
130
+ ? `Recorded the consumes edge: ${serviceName} (via ${via})`
131
+ : `Consumes edge for ${serviceName} already declared`);
132
+ }
133
+ // Best-effort health probe against the issued endpoint; warns, never blocks.
134
+ async function probeHealth(entry, credential) {
135
+ const healthPath = entry?.http?.health ?? "/health";
136
+ const url = credential.endpoint.replace(/\/+$/, "") + healthPath;
137
+ try {
138
+ const response = await fetch(url, {
139
+ headers: { Authorization: `Bearer ${credential.serviceKey}` },
140
+ signal: AbortSignal.timeout(5_000),
141
+ });
142
+ if (response.ok) {
143
+ p.log.success(`Health check passed (${url})`);
144
+ }
145
+ else {
146
+ p.log.warn(`Health check returned HTTP ${response.status} (${url}) — non-blocking`);
147
+ }
148
+ }
149
+ catch {
150
+ p.log.warn(`Health probe unreachable (${url}) — non-blocking`);
151
+ }
152
+ }
@@ -0,0 +1,36 @@
1
+ // packages/cli/src/commands/describe.ts - `apollo describe <service>`: the full
2
+ // catalogue entry for one service, prettified or raw JSON.
3
+ import * as p from "@clack/prompts";
4
+ import { fetchCatalogue, resolveApolloUrl } from "../lib/apollo_client.js";
5
+ // Print one catalogue entry; --json emits the raw object for piping.
6
+ export async function runDescribe(serviceName, options, globals) {
7
+ const baseUrl = resolveApolloUrl(globals);
8
+ const catalogue = await fetchCatalogue(baseUrl);
9
+ const entry = (catalogue.services ?? []).find((service) => service.name === serviceName);
10
+ if (!entry) {
11
+ p.log.error(`'${serviceName}' is not in the catalogue at ${baseUrl} (${catalogue.services?.length ?? 0} services listed).`);
12
+ process.exitCode = 1;
13
+ return;
14
+ }
15
+ if (options.json) {
16
+ console.log(JSON.stringify(entry, null, 2));
17
+ return;
18
+ }
19
+ console.log(`${entry.name} — ${entry.description ?? "(no description)"}`);
20
+ console.log(` kind/type: ${entry.kind ?? "?"} / ${entry.type ?? "?"}`);
21
+ console.log(` status: ${entry.status ?? "?"}`);
22
+ if (entry.repository)
23
+ console.log(` repository: ${entry.repository}`);
24
+ if (entry.npm?.package)
25
+ console.log(` npm: ${entry.npm.package}`);
26
+ for (const [envName, url] of Object.entries(entry.http?.endpoints ?? {})) {
27
+ console.log(` endpoint: ${envName} → ${url}`);
28
+ }
29
+ if (entry.env?.required?.length)
30
+ console.log(` env (required): ${entry.env.required.join(", ")}`);
31
+ if (entry.env?.optional?.length)
32
+ console.log(` env (optional): ${entry.env.optional.join(", ")}`);
33
+ for (const edge of entry.consumes ?? []) {
34
+ console.log(` consumes: ${edge.service}${edge.via ? ` (via ${edge.via})` : ""}`);
35
+ }
36
+ }
@@ -0,0 +1,130 @@
1
+ // packages/cli/src/commands/doctor.ts - `apollo doctor`: repo health — key
2
+ // validity, per-credential health probes, manifest schema validity, and
3
+ // consumes edges cross-checked against the catalogue. Warnings inform;
4
+ // errors exit 1.
5
+ import path from "node:path";
6
+ import { readFile } from "node:fs/promises";
7
+ import * as p from "@clack/prompts";
8
+ import { fetchCatalogue, resolveApolloUrl, validateAppKey, } from "../lib/apollo_client.js";
9
+ import { readManagedValues } from "../lib/env_file.js";
10
+ import { hasManifest, manifestPath } from "../lib/manifest.js";
11
+ import { loadSchema, validateManifest } from "../lib/schema_validator.js";
12
+ // Run every check; exit 1 when any error (not warning) was found.
13
+ export async function runDoctor(globals) {
14
+ const cwd = process.cwd();
15
+ const baseUrl = resolveApolloUrl(globals);
16
+ p.intro("apollo doctor");
17
+ const counters = { errors: 0, warnings: 0 };
18
+ const managedValues = await readManagedValues(path.join(cwd, ".env"));
19
+ await checkAppKey(managedValues, baseUrl, counters);
20
+ await checkServiceCredentials(managedValues, counters);
21
+ await checkManifest(cwd, baseUrl, counters);
22
+ const summary = `${counters.errors} error(s), ${counters.warnings} warning(s)`;
23
+ if (counters.errors > 0) {
24
+ p.outro(`Unhealthy — ${summary}.`);
25
+ process.exitCode = 1;
26
+ }
27
+ else {
28
+ p.outro(`Healthy — ${summary}.`);
29
+ }
30
+ }
31
+ // Check the APOLLO_APP_KEY exists and is still accepted by Apollo.
32
+ async function checkAppKey(managedValues, baseUrl, counters) {
33
+ const appKey = managedValues.APOLLO_APP_KEY ?? process.env.APOLLO_APP_KEY;
34
+ if (!appKey) {
35
+ p.log.error("APOLLO_APP_KEY missing — run `apollo init`.");
36
+ counters.errors += 1;
37
+ return;
38
+ }
39
+ const isValid = await validateAppKey(baseUrl, appKey).catch(() => false);
40
+ if (isValid) {
41
+ p.log.success("APOLLO_APP_KEY accepted by Apollo");
42
+ }
43
+ else {
44
+ p.log.error("APOLLO_APP_KEY rejected (revoked or invalid) — re-run `apollo init`.");
45
+ counters.errors += 1;
46
+ }
47
+ }
48
+ // Probe /health for every <PREFIX>_ENDPOINT/<PREFIX>_KEY pair in the managed block.
49
+ async function checkServiceCredentials(managedValues, counters) {
50
+ const endpointKeys = Object.keys(managedValues).filter((key) => key.endsWith("_ENDPOINT") && key !== "APOLLO_APP_KEY");
51
+ if (endpointKeys.length === 0) {
52
+ p.log.info("No service credentials in the managed block yet.");
53
+ return;
54
+ }
55
+ for (const endpointKey of endpointKeys) {
56
+ const prefix = endpointKey.slice(0, -"_ENDPOINT".length);
57
+ const endpoint = managedValues[endpointKey];
58
+ const serviceKey = managedValues[`${prefix}_KEY`];
59
+ if (!serviceKey) {
60
+ p.log.warn(`${prefix}: endpoint present but ${prefix}_KEY missing — run \`apollo sync\`.`);
61
+ counters.warnings += 1;
62
+ continue;
63
+ }
64
+ try {
65
+ const response = await fetch(`${endpoint.replace(/\/+$/, "")}/health`, {
66
+ headers: { Authorization: `Bearer ${serviceKey}` },
67
+ signal: AbortSignal.timeout(5_000),
68
+ });
69
+ if (response.ok) {
70
+ p.log.success(`${prefix}: health OK (${endpoint})`);
71
+ }
72
+ else {
73
+ p.log.warn(`${prefix}: health returned HTTP ${response.status} (${endpoint})`);
74
+ counters.warnings += 1;
75
+ }
76
+ }
77
+ catch {
78
+ p.log.warn(`${prefix}: unreachable (${endpoint})`);
79
+ counters.warnings += 1;
80
+ }
81
+ }
82
+ }
83
+ // Validate the manifest and cross-check its consumes edges against the catalogue.
84
+ async function checkManifest(cwd, baseUrl, counters) {
85
+ if (!hasManifest(cwd)) {
86
+ p.log.warn("No apollo.service.json — the app has no bill of materials. `apollo init` scaffolds one.");
87
+ counters.warnings += 1;
88
+ return;
89
+ }
90
+ let manifest;
91
+ try {
92
+ manifest = JSON.parse(await readFile(manifestPath(cwd), "utf8"));
93
+ }
94
+ catch {
95
+ p.log.error("apollo.service.json is not valid JSON.");
96
+ counters.errors += 1;
97
+ return;
98
+ }
99
+ const schema = await loadSchema().catch(() => null);
100
+ if (schema) {
101
+ const result = validateManifest(schema, manifest);
102
+ if (result.isValid) {
103
+ p.log.success("apollo.service.json is schema-valid");
104
+ }
105
+ else {
106
+ p.log.error(`apollo.service.json fails the schema: ${result.errors.join("; ")}`);
107
+ counters.errors += 1;
108
+ }
109
+ }
110
+ const edges = (manifest.services ?? []).flatMap((service) => service.consumes ?? []);
111
+ if (edges.length === 0)
112
+ return;
113
+ const catalogue = await fetchCatalogue(baseUrl).catch(() => null);
114
+ const services = catalogue?.services ?? [];
115
+ if (services.length === 0) {
116
+ p.log.info("Catalogue empty — consumes edges not cross-checked.");
117
+ return;
118
+ }
119
+ for (const edge of edges) {
120
+ const entry = services.find((service) => service.name === edge.service);
121
+ if (!entry) {
122
+ p.log.warn(`consumes '${edge.service}' — not found in the catalogue.`);
123
+ counters.warnings += 1;
124
+ }
125
+ else if (entry.status === "deprecated") {
126
+ p.log.warn(`consumes '${edge.service}' — service is deprecated; plan a migration.`);
127
+ counters.warnings += 1;
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,36 @@
1
+ // packages/cli/src/commands/graph.ts - `apollo graph`: render the catalogue's
2
+ // consumes-graph as an ascii edge list or a mermaid block.
3
+ import { fetchCatalogue, resolveApolloUrl } from "../lib/apollo_client.js";
4
+ // Print every consumes edge in the catalogue; --mermaid emits `graph LR`.
5
+ export async function runGraph(options, globals) {
6
+ const baseUrl = resolveApolloUrl(globals);
7
+ const catalogue = await fetchCatalogue(baseUrl);
8
+ const services = catalogue.services ?? [];
9
+ const edges = services.flatMap((service) => (service.consumes ?? []).map((edge) => ({
10
+ from: service.name,
11
+ to: edge.service,
12
+ via: edge.via ?? "http",
13
+ })));
14
+ if (services.length === 0) {
15
+ console.log(`Catalogue at ${baseUrl} is empty — nothing to graph.`);
16
+ return;
17
+ }
18
+ if (edges.length === 0) {
19
+ console.log(`${services.length} service(s) in the catalogue, but no consumes edges declared.`);
20
+ return;
21
+ }
22
+ if (options.mermaid) {
23
+ console.log("graph LR");
24
+ for (const edge of edges) {
25
+ console.log(` ${sanitizeMermaidId(edge.from)} -->|${edge.via}| ${sanitizeMermaidId(edge.to)}`);
26
+ }
27
+ return;
28
+ }
29
+ for (const edge of edges) {
30
+ console.log(`${edge.from.padEnd(30)} ──${edge.via}──▶ ${edge.to}`);
31
+ }
32
+ }
33
+ // Mermaid node ids can't contain slashes/dots — normalize while keeping them readable.
34
+ function sanitizeMermaidId(name) {
35
+ return name.replace(/[^A-Za-z0-9_-]/g, "_");
36
+ }
@@ -0,0 +1,73 @@
1
+ // packages/cli/src/commands/init.ts - `apollo init`: store the APOLLO_APP_KEY in
2
+ // the managed .env block (validated against Apollo — no key, no install),
3
+ // gitignore .env, and scaffold the repo manifest.
4
+ import path from "node:path";
5
+ import * as p from "@clack/prompts";
6
+ import { resolveApolloUrl, validateAppKey } from "../lib/apollo_client.js";
7
+ import { ensureEnvGitignored, readManagedValues, upsertManagedValues } from "../lib/env_file.js";
8
+ import { hasManifest, scaffoldManifest } from "../lib/manifest.js";
9
+ import { isInteractive } from "../lib/interactive.js";
10
+ // Run the init flow: collect + validate the key, write managed blocks, scaffold.
11
+ export async function runInit(options) {
12
+ const cwd = process.cwd();
13
+ const baseUrl = resolveApolloUrl(options);
14
+ p.intro("apollo init");
15
+ if (!isInteractive()) {
16
+ p.log.error("apollo init needs an interactive terminal (it prompts for the key).");
17
+ p.outro("Re-run in a terminal, or write APOLLO_APP_KEY into the managed .env block directly.");
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+ const managedValues = await readManagedValues(path.join(cwd, ".env"));
22
+ if (managedValues.APOLLO_APP_KEY) {
23
+ const shouldReplace = await p.confirm({
24
+ message: "An APOLLO_APP_KEY is already configured here. Replace it?",
25
+ });
26
+ if (p.isCancel(shouldReplace) || !shouldReplace) {
27
+ p.cancel("Kept the existing key — nothing changed.");
28
+ return;
29
+ }
30
+ }
31
+ const appKey = await p.password({
32
+ message: `Paste your APOLLO_APP_KEY (create an application in the Apollo console: ${baseUrl})`,
33
+ validate: (value) => value?.startsWith("apollo_app_") ? undefined : "Expected a key starting with apollo_app_",
34
+ });
35
+ if (p.isCancel(appKey)) {
36
+ p.cancel("init cancelled — nothing written.");
37
+ return;
38
+ }
39
+ const spinner = p.spinner();
40
+ spinner.start("Validating the key against Apollo");
41
+ const isValid = await validateAppKey(baseUrl, appKey).catch(() => false);
42
+ if (!isValid) {
43
+ spinner.stop("Key rejected by Apollo", 1);
44
+ p.outro("No key, no install — fix the key and re-run `apollo init`.");
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ spinner.stop("Key accepted");
49
+ await upsertManagedValues(path.join(cwd, ".env"), { APOLLO_APP_KEY: appKey });
50
+ await upsertManagedValues(path.join(cwd, ".env.example"), { APOLLO_APP_KEY: "" });
51
+ if (await ensureEnvGitignored(cwd)) {
52
+ p.log.info("Added .env to .gitignore");
53
+ }
54
+ if (!hasManifest(cwd)) {
55
+ const repoName = deriveRepoName(cwd);
56
+ const shouldScaffold = await p.confirm({
57
+ message: `No apollo.service.json found — scaffold one for "${repoName}"? Its consumes[] edges are the app's bill of materials.`,
58
+ });
59
+ if (!p.isCancel(shouldScaffold) && shouldScaffold) {
60
+ await scaffoldManifest(cwd, repoName);
61
+ p.log.success("Scaffolded apollo.service.json");
62
+ }
63
+ }
64
+ p.outro("Ready — run `apollo add <service>` to wire in your first stack service.");
65
+ }
66
+ // Derive a spec-conformant repo name (lowercase, hyphenated) from the folder name.
67
+ function deriveRepoName(cwd) {
68
+ return path
69
+ .basename(cwd)
70
+ .toLowerCase()
71
+ .replace(/[^a-z0-9-]+/g, "-")
72
+ .replace(/^-+|-+$/g, "");
73
+ }
@@ -0,0 +1,18 @@
1
+ // packages/cli/src/commands/list.ts - `apollo list`: print the discovery
2
+ // catalogue, one line per service.
3
+ import { fetchCatalogue, resolveApolloUrl } from "../lib/apollo_client.js";
4
+ // Print the catalogue as aligned columns: name, classifier, status, description.
5
+ export async function runList(options) {
6
+ const baseUrl = resolveApolloUrl(options);
7
+ const catalogue = await fetchCatalogue(baseUrl);
8
+ const services = catalogue.services ?? [];
9
+ if (services.length === 0) {
10
+ console.log(`Catalogue at ${baseUrl} is empty — the registry read path / refresh isn't wired yet.`);
11
+ return;
12
+ }
13
+ for (const service of services) {
14
+ const classifier = service.type ?? service.kind ?? "?";
15
+ const status = service.status ?? "?";
16
+ console.log(`${service.name.padEnd(30)} ${classifier.padEnd(18)} ${status.padEnd(14)} ${service.description ?? ""}`);
17
+ }
18
+ }
@@ -0,0 +1,37 @@
1
+ // packages/cli/src/commands/open.ts - `apollo open <service>`: open the
2
+ // service's endpoint (or repository) in the default browser.
3
+ import { spawn } from "node:child_process";
4
+ import * as p from "@clack/prompts";
5
+ import { fetchCatalogue, resolveApolloUrl } from "../lib/apollo_client.js";
6
+ // Resolve the URL to open (chosen endpoint > production > any > repository) and launch it.
7
+ export async function runOpen(serviceName, options, globals) {
8
+ const baseUrl = resolveApolloUrl(globals);
9
+ const catalogue = await fetchCatalogue(baseUrl);
10
+ const entry = (catalogue.services ?? []).find((service) => service.name === serviceName);
11
+ if (!entry) {
12
+ p.log.error(`'${serviceName}' is not in the catalogue at ${baseUrl}.`);
13
+ process.exitCode = 1;
14
+ return;
15
+ }
16
+ const endpoints = entry.http?.endpoints ?? {};
17
+ const url = (options.env ? endpoints[options.env] : undefined) ??
18
+ endpoints["production"] ??
19
+ Object.values(endpoints)[0] ??
20
+ entry.repository;
21
+ if (!url) {
22
+ p.log.error(`'${serviceName}' declares no endpoints and no repository to open.`);
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+ launchBrowser(url);
27
+ p.log.success(`Opening ${url}`);
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
+ }
@@ -0,0 +1,57 @@
1
+ // packages/cli/src/commands/sync.ts - `apollo sync`: re-project the
2
+ // application's full server-side credential set into the managed .env block
3
+ // (consumer-journey.md §6 — the server wins inside the block; stale
4
+ // credentials disappear).
5
+ import path from "node:path";
6
+ import * as p from "@clack/prompts";
7
+ import { fetchAppCredentials, resolveApolloUrl } from "../lib/apollo_client.js";
8
+ import { readManagedValues, upsertManagedValues, writeManagedValues } from "../lib/env_file.js";
9
+ // Run the sync flow: fetch the projection, rewrite the managed block wholly.
10
+ export async function runSync(options) {
11
+ const cwd = process.cwd();
12
+ const baseUrl = resolveApolloUrl(options);
13
+ p.intro("apollo sync");
14
+ const managedValues = await readManagedValues(path.join(cwd, ".env"));
15
+ const appKey = managedValues.APOLLO_APP_KEY ?? process.env.APOLLO_APP_KEY;
16
+ if (!appKey) {
17
+ p.log.error("No APOLLO_APP_KEY found (managed .env block or environment).");
18
+ p.outro("Run `apollo init` first — no key, no sync.");
19
+ process.exitCode = 1;
20
+ return;
21
+ }
22
+ const spinner = p.spinner();
23
+ spinner.start("Fetching the application's credential projection");
24
+ let projection;
25
+ try {
26
+ projection = await fetchAppCredentials(baseUrl, appKey);
27
+ }
28
+ catch (error) {
29
+ spinner.stop("Projection failed", 1);
30
+ p.log.error(error instanceof Error ? error.message : String(error));
31
+ p.outro("The managed block was not touched.");
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+ spinner.stop(`Application '${projection.app.name}' (${projection.app.environment}): ${projection.credentials.length} credential(s)`);
36
+ const nextValues = { APOLLO_APP_KEY: appKey };
37
+ const placeholders = { APOLLO_APP_KEY: "" };
38
+ for (const credential of [...projection.credentials].sort((a, b) => a.service.localeCompare(b.service))) {
39
+ const prefix = toEnvPrefix(credential.service);
40
+ nextValues[`${prefix}_ENDPOINT`] = credential.endpoint;
41
+ nextValues[`${prefix}_KEY`] = credential.serviceKey;
42
+ placeholders[`${prefix}_ENDPOINT`] = "";
43
+ placeholders[`${prefix}_KEY`] = "";
44
+ }
45
+ const removedKeys = Object.keys(managedValues).filter((key) => !(key in nextValues));
46
+ await writeManagedValues(path.join(cwd, ".env"), nextValues);
47
+ await upsertManagedValues(path.join(cwd, ".env.example"), placeholders);
48
+ p.log.success(`Managed block rewritten: ${projection.credentials.length} service credential(s).`);
49
+ if (removedKeys.length > 0) {
50
+ p.log.info(`Removed stale entries: ${removedKeys.join(", ")}`);
51
+ }
52
+ p.outro("In sync with Apollo.");
53
+ }
54
+ // Derive the env-var prefix from the service name (nustack-rag → NUSTACK_RAG).
55
+ function toEnvPrefix(serviceName) {
56
+ return serviceName.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
57
+ }