@namiml/cli 3.4.11-dev.202609221916

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 (52) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +21 -0
  3. package/dist/App.js +22 -0
  4. package/dist/adapters/file-storage.adapter.js +87 -0
  5. package/dist/adapters/index.js +18 -0
  6. package/dist/adapters/terminal-device.adapter.js +39 -0
  7. package/dist/adapters/terminal-purchase.adapter.js +12 -0
  8. package/dist/adapters/terminal-storage.adapter.js +19 -0
  9. package/dist/adapters/terminal-ui.adapter.js +23 -0
  10. package/dist/auth/control-center.js +130 -0
  11. package/dist/auth/errors.js +10 -0
  12. package/dist/auth/redact.js +11 -0
  13. package/dist/auth/session.js +56 -0
  14. package/dist/auth/token-store.js +36 -0
  15. package/dist/cli.js +28 -0
  16. package/dist/commands/apps.js +26 -0
  17. package/dist/commands/config.js +20 -0
  18. package/dist/commands/configure.js +29 -0
  19. package/dist/commands/context.js +26 -0
  20. package/dist/commands/list-output.js +9 -0
  21. package/dist/commands/login.js +21 -0
  22. package/dist/commands/logout.js +7 -0
  23. package/dist/commands/onboarding.js +13 -0
  24. package/dist/commands/platforms.js +34 -0
  25. package/dist/commands/shell.js +16 -0
  26. package/dist/commands/whoami.js +42 -0
  27. package/dist/components/SelectList.js +40 -0
  28. package/dist/components/Thinking.js +20 -0
  29. package/dist/components/Wave.js +30 -0
  30. package/dist/components/wave-frame.js +21 -0
  31. package/dist/config/selection.js +22 -0
  32. package/dist/config/store.js +132 -0
  33. package/dist/control-center/apps.js +15 -0
  34. package/dist/control-center/errors.js +10 -0
  35. package/dist/control-center/pagination.js +29 -0
  36. package/dist/control-center/platforms.js +28 -0
  37. package/dist/control-center/resolve.js +35 -0
  38. package/dist/hosts.js +13 -0
  39. package/dist/paths.js +5 -0
  40. package/dist/program.js +100 -0
  41. package/dist/screens/ConfigureScreen.js +53 -0
  42. package/dist/screens/LoginFlow.js +99 -0
  43. package/dist/screens/PickApp.js +11 -0
  44. package/dist/screens/PickPlatform.js +38 -0
  45. package/dist/screens/SelectionHeader.js +8 -0
  46. package/dist/screens/Shell.js +114 -0
  47. package/dist/sdk/configure.js +59 -0
  48. package/dist/sdk/verify.js +42 -0
  49. package/dist/theme.js +22 -0
  50. package/dist/utils/terminal.js +17 -0
  51. package/dist/version.js +2 -0
  52. package/package.json +45 -0
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from "ink";
3
+ import { LoginFlow } from "../screens/LoginFlow.js";
4
+ /** Interactive sign-in. Returns the process exit code. */
5
+ export async function login(opts, ctx) {
6
+ if (!ctx.isTTY) {
7
+ ctx.err("`nami login` needs an interactive terminal. In CI or scripts set NAMI_TOKEN instead, or pass --app-platform-id where supported.");
8
+ return 2;
9
+ }
10
+ let result = null;
11
+ let failed = false;
12
+ const app = render(_jsx(LoginFlow, { auth: ctx.auth, store: ctx.store, host: ctx.host, totpCode: opts.totpCode, org: opts.org, onDone: (s) => { result = s; }, onFail: () => { failed = true; } }));
13
+ await app.waitUntilExit();
14
+ if (failed || !result)
15
+ return 1;
16
+ if (opts.printToken) {
17
+ // Explicit opt-in only: lets a developer mint NAMI_TOKEN for CI. Not written anywhere by the CLI.
18
+ ctx.out(result.accessToken);
19
+ }
20
+ return 0;
21
+ }
@@ -0,0 +1,7 @@
1
+ /** Forget the stored tokens. Returns the process exit code. */
2
+ export async function logout(ctx) {
3
+ const had = (await ctx.store.load()) !== null;
4
+ await ctx.store.clear();
5
+ ctx.out(had ? `Signed out of ${ctx.host.name}.` : "You were not signed in.");
6
+ return 0;
7
+ }
@@ -0,0 +1,13 @@
1
+ /** `nami onboarding [--reset]` — show or reset the onboarding state. Tokens are never touched. */
2
+ export async function onboarding(opts, ctx) {
3
+ if (opts.reset) {
4
+ await ctx.config.resetOnboarding();
5
+ ctx.out("Onboarding reset — run nami to pick an app and platform again. Your sign-in was kept (nami logout removes it).");
6
+ return 0;
7
+ }
8
+ const { config } = await ctx.config.load();
9
+ ctx.out(`Onboarding step: ${config.onboarding.step}${config.onboarding.completedAt ? ` (completed ${config.onboarding.completedAt})` : ""}`);
10
+ if (config.onboarding.step !== "done")
11
+ ctx.out("Run nami to continue.");
12
+ return 0;
13
+ }
@@ -0,0 +1,34 @@
1
+ import { AuthError } from "../auth/errors.js";
2
+ import { listApps } from "../control-center/apps.js";
3
+ import { listPlatforms } from "../control-center/platforms.js";
4
+ import { findApp, ResolveError } from "../control-center/resolve.js";
5
+ import { renderTable } from "./list-output.js";
6
+ /** `nami platforms --app <id|name>` — list an app's platforms. `id` is the App Platform ID. */
7
+ export async function platforms(opts, ctx) {
8
+ try {
9
+ const app = findApp(await listApps(ctx.session, { includeArchived: true }), opts.app);
10
+ const items = await listPlatforms(ctx.session, app.id, { includeArchived: Boolean(opts.all) });
11
+ if (opts.json) {
12
+ ctx.out(JSON.stringify(items, null, 2));
13
+ }
14
+ else if (items.length === 0) {
15
+ ctx.out(`${app.name} has no platforms${opts.all ? "" : " (archived ones hidden; pass --all)"}.`);
16
+ }
17
+ else {
18
+ ctx.out(`Platforms for ${app.name} — the id column is the App Platform ID`);
19
+ ctx.out(renderTable(items, ["id", "name", "typeLabel", "sdkEnabled", "isConfigValid", "archived"]));
20
+ }
21
+ return 0;
22
+ }
23
+ catch (error) {
24
+ if (error instanceof ResolveError) {
25
+ ctx.err(error.message);
26
+ return 2;
27
+ }
28
+ if (error instanceof AuthError) {
29
+ ctx.err(error.message);
30
+ return 1;
31
+ }
32
+ throw error;
33
+ }
34
+ }
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { render, Box, Text } from "ink";
3
+ import { App } from "../App.js";
4
+ import { Shell } from "../screens/Shell.js";
5
+ /** Bare `nami`: the interactive shell on a TTY; the static placeholder elsewhere. */
6
+ export async function shell(opts, ctx) {
7
+ if (!ctx.isTTY) {
8
+ const app = render(_jsxs(Box, { flexDirection: "column", children: [_jsx(App, { demo: false }), _jsx(Text, { dimColor: true, children: "nami is interactive \u2014 run it in a terminal, or use subcommands: nami apps, nami platforms --app \u2026, nami whoami." })] }));
9
+ app.unmount();
10
+ return 0;
11
+ }
12
+ let code = 0;
13
+ const app = render(_jsx(Shell, { ctx: ctx, appQuery: opts.app, platformQuery: opts.platform, onExit: (c) => { code = c; } }));
14
+ await app.waitUntilExit();
15
+ return code;
16
+ }
@@ -0,0 +1,42 @@
1
+ import { AuthError } from "../auth/errors.js";
2
+ /** Show who is signed in. Never prints tokens. Returns the process exit code. */
3
+ export async function whoami(opts, ctx) {
4
+ const stored = await ctx.store.load();
5
+ const viaToken = Boolean(ctx.env.NAMI_TOKEN);
6
+ if (!stored && !viaToken) {
7
+ ctx.err("Not signed in. Run `nami login` first.");
8
+ return 1;
9
+ }
10
+ let username = null;
11
+ try {
12
+ const res = await ctx.session.authorizedFetch("/private/account/");
13
+ if (res.ok) {
14
+ const data = (await res.json());
15
+ username = typeof data.username === "string" ? data.username : null;
16
+ }
17
+ }
18
+ catch (error) {
19
+ if (error instanceof AuthError && error.code === "session_expired") {
20
+ ctx.err(error.message);
21
+ return 1;
22
+ }
23
+ throw error;
24
+ }
25
+ const identity = {
26
+ host: ctx.host.name,
27
+ baseUrl: ctx.host.baseUrl,
28
+ orgId: stored?.orgId ?? null,
29
+ orgName: stored?.orgName ?? null,
30
+ username,
31
+ source: viaToken ? "NAMI_TOKEN" : "session",
32
+ };
33
+ if (opts.json) {
34
+ ctx.out(JSON.stringify(identity, null, 2));
35
+ }
36
+ else {
37
+ const who = identity.username ?? "an access token";
38
+ const org = identity.orgName ? ` · org ${identity.orgName}` : "";
39
+ ctx.out(`Signed in as ${who} on ${identity.host} (${identity.baseUrl})${org}${viaToken ? " · via NAMI_TOKEN" : ""}`);
40
+ }
41
+ return 0;
42
+ }
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { palette } from "../theme.js";
5
+ /**
6
+ * Keyboard list: ↑/↓ move (wrapping), Enter selects, typing filters,
7
+ * Backspace un-filters, Esc cancels. Pure: no data loading here.
8
+ */
9
+ export function SelectList({ title, items, initialKey, onSelect, onCancel, emptyText = "Nothing to choose from.", footer }) {
10
+ const [filter, setFilter] = useState("");
11
+ const [cursor, setCursor] = useState(() => Math.max(0, items.findIndex((i) => i.key === initialKey)));
12
+ const visible = useMemo(() => {
13
+ const q = filter.trim().toLowerCase();
14
+ if (!q)
15
+ return items;
16
+ return items.filter((i) => `${i.label} ${i.hint ?? ""} ${i.badge ?? ""}`.toLowerCase().includes(q));
17
+ }, [items, filter]);
18
+ const index = visible.length === 0 ? 0 : Math.min(cursor, visible.length - 1);
19
+ useInput((input, key) => {
20
+ if (key.escape)
21
+ return onCancel?.();
22
+ if (key.return) {
23
+ const item = visible[index];
24
+ if (item)
25
+ onSelect(item.key);
26
+ return;
27
+ }
28
+ if (key.upArrow)
29
+ return setCursor(visible.length ? (index - 1 + visible.length) % visible.length : 0);
30
+ if (key.downArrow)
31
+ return setCursor(visible.length ? (index + 1) % visible.length : 0);
32
+ if (key.backspace || key.delete)
33
+ return setFilter((f) => f.slice(0, -1));
34
+ if (input && !key.ctrl && !key.meta) {
35
+ setFilter((f) => f + input);
36
+ setCursor(0);
37
+ }
38
+ });
39
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: title }), filter && _jsxs(Text, { dimColor: true, children: [" filter: ", filter, " (", visible.length, " of ", items.length, ")"] })] }), visible.length === 0 ? (_jsx(Text, { dimColor: true, children: items.length === 0 ? emptyText : "No matches — Backspace to widen." })) : (visible.map((item, i) => (_jsxs(Box, { children: [_jsx(Text, { color: i === index ? palette.aqua : undefined, children: i === index ? "❯ " : " " }), item.badge && _jsxs(Text, { color: palette.secondaryBlue, children: ["[", item.badge, "] "] }), _jsx(Text, { bold: i === index, children: item.label }), item.hint && _jsxs(Text, { dimColor: true, children: [" ", item.hint] }), item.warn && _jsxs(Text, { color: palette.coral, children: [" ", item.warn] })] }, item.key)))), _jsx(Text, { dimColor: true, children: footer ?? "↑/↓ move · Enter select · type to filter · Esc back" })] }));
40
+ }
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text, useStdout } from "ink";
4
+ import { Wave } from "./Wave.js";
5
+ import { shouldAnimate } from "../utils/terminal.js";
6
+ const elapsedSeconds = (start, now) => Math.max(0, Math.floor((now() - start) / 1000));
7
+ /** Standard busy indicator: the tide, a label and elapsed seconds. */
8
+ export function Thinking({ label, startedAt, now = Date.now, phase, width }) {
9
+ const { stdout } = useStdout();
10
+ const [start] = useState(() => startedAt ?? now());
11
+ const [elapsed, setElapsed] = useState(() => elapsedSeconds(start, now));
12
+ const live = phase === undefined && shouldAnimate(process.env, stdout);
13
+ useEffect(() => {
14
+ if (!live)
15
+ return;
16
+ const id = setInterval(() => setElapsed(elapsedSeconds(start, now)), 1000);
17
+ return () => clearInterval(id);
18
+ }, [live, now, start]);
19
+ return (_jsxs(Box, { children: [_jsx(Wave, { phase: phase, width: width }), _jsxs(Text, { children: [" ", label] }), _jsxs(Text, { dimColor: true, children: [" (", elapsed, "s)"] })] }));
20
+ }
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text, useStdout } from "ink";
4
+ import { MIN_WAVE_WIDTH, renderWaveFrame } from "./wave-frame.js";
5
+ import { shouldAnimate, terminalColumns } from "../utils/terminal.js";
6
+ const PHASE_STEP = (2 * Math.PI) / 24;
7
+ /** The Nami tide. Animates only on an interactive TTY (see `shouldAnimate`). */
8
+ export function Wave({ width = 12, phase, fps = 12, animate = true, wavelength = 8 }) {
9
+ const { stdout } = useStdout();
10
+ const [tick, setTick] = useState(0);
11
+ const [columns, setColumns] = useState(() => terminalColumns(stdout));
12
+ const controlled = phase !== undefined;
13
+ const live = !controlled && animate && shouldAnimate(process.env, stdout);
14
+ useEffect(() => {
15
+ if (!live)
16
+ return;
17
+ const id = setInterval(() => setTick((t) => t + 1), Math.max(16, Math.round(1000 / fps)));
18
+ return () => clearInterval(id);
19
+ }, [live, fps]);
20
+ useEffect(() => {
21
+ const onResize = () => setColumns(terminalColumns(stdout));
22
+ stdout.on("resize", onResize);
23
+ return () => {
24
+ stdout.off("resize", onResize);
25
+ };
26
+ }, [stdout]);
27
+ const effectiveWidth = Math.max(MIN_WAVE_WIDTH, Math.min(width, columns - 2));
28
+ const frame = renderWaveFrame(controlled ? phase : tick * PHASE_STEP, effectiveWidth, wavelength);
29
+ return (_jsx(Text, { children: frame.map((column, i) => (_jsx(Text, { color: column.color, children: column.glyph }, i))) }));
30
+ }
@@ -0,0 +1,21 @@
1
+ import { WAVE_COLORS } from "../theme.js";
2
+ export const WAVE_GLYPHS = "▁▂▃▄▅▆▇█";
3
+ export const MIN_WAVE_WIDTH = 4;
4
+ const GLYPHS = Array.from(WAVE_GLYPHS);
5
+ const MAX_HEIGHT = GLYPHS.length - 1;
6
+ /**
7
+ * One frame of the tide: a sine wave sampled per column, offset by `phase`
8
+ * (radians). Pure — components and tests share it, so animation is just
9
+ * "advance the phase".
10
+ */
11
+ export function renderWaveFrame(phase, width, wavelength = 8) {
12
+ const columns = Math.max(MIN_WAVE_WIDTH, Math.floor(width));
13
+ const frame = [];
14
+ for (let i = 0; i < columns; i++) {
15
+ const level = (Math.sin((2 * Math.PI * i) / wavelength - phase) + 1) / 2;
16
+ const height = Math.round(level * MAX_HEIGHT);
17
+ const band = Math.min(WAVE_COLORS.length - 1, Math.floor(height / 2));
18
+ frame.push({ glyph: GLYPHS[height], color: WAVE_COLORS[band] });
19
+ }
20
+ return frame;
21
+ }
@@ -0,0 +1,22 @@
1
+ import { platformTypeLabel } from "../control-center/platforms.js";
2
+ export const selectionFrom = (app, platform, device = null) => ({
3
+ appId: app.id,
4
+ appName: app.name,
5
+ appPlatformId: platform.id,
6
+ platformName: platform.name,
7
+ platformType: platform.type,
8
+ device,
9
+ });
10
+ /** Rebuild the picker objects from a stored selection (flags default to "fine" — verify decides). */
11
+ export const appFrom = (s) => ({ id: s.appId, name: s.appName, archived: false, defaultLanguage: null, logo: null });
12
+ export const platformFrom = (s) => ({
13
+ id: s.appPlatformId,
14
+ appId: s.appId,
15
+ name: s.platformName,
16
+ type: s.platformType,
17
+ typeLabel: platformTypeLabel(s.platformType),
18
+ sdkEnabled: true,
19
+ isConfigValid: true,
20
+ archived: false,
21
+ marketplaceAppId: null,
22
+ });
@@ -0,0 +1,132 @@
1
+ import { mkdir, readFile, rename, rm, writeFile, chmod } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export const CONFIG_SCHEMA_VERSION = 1;
4
+ export const RECENT_MAX = 5;
5
+ export const defaultConfig = () => ({
6
+ schemaVersion: CONFIG_SCHEMA_VERSION,
7
+ host: "production",
8
+ session: { orgId: null, orgName: null },
9
+ current: null,
10
+ recent: [],
11
+ sdkPlatform: null,
12
+ onboarding: { step: "welcome", completedAt: null },
13
+ });
14
+ const CREDENTIAL_KEY = /token|password|secret|passcode|totp/i;
15
+ const JWT_LIKE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
16
+ /** `config.json` must never carry credentials, even by accident. */
17
+ function assertNoCredentials(value, path = "config") {
18
+ if (typeof value === "string") {
19
+ if (JWT_LIKE.test(value))
20
+ throw new Error(`Refusing to write a credential-looking value at ${path} to config.json.`);
21
+ return;
22
+ }
23
+ if (Array.isArray(value))
24
+ return value.forEach((v, i) => assertNoCredentials(v, `${path}[${i}]`));
25
+ if (value && typeof value === "object") {
26
+ for (const [k, v] of Object.entries(value)) {
27
+ if (CREDENTIAL_KEY.test(k))
28
+ throw new Error(`Refusing to write credential key "${k}" at ${path} to config.json.`);
29
+ assertNoCredentials(v, `${path}.${k}`);
30
+ }
31
+ }
32
+ }
33
+ const recentKey = (s) => `${s.appPlatformId}::${s.device?.id ?? ""}`;
34
+ /** Upgrade older shapes; unknown versions fall back to defaults (with a warning from load()). */
35
+ export function migrate(raw) {
36
+ if (!raw || typeof raw !== "object")
37
+ return { config: defaultConfig(), warning: "config.json was not an object — starting fresh." };
38
+ const r = raw;
39
+ if (r.schemaVersion !== CONFIG_SCHEMA_VERSION) {
40
+ return { config: defaultConfig(), warning: `config.json has schemaVersion ${String(r.schemaVersion)} (this CLI writes ${CONFIG_SCHEMA_VERSION}) — starting fresh.` };
41
+ }
42
+ const d = defaultConfig();
43
+ return {
44
+ config: {
45
+ schemaVersion: CONFIG_SCHEMA_VERSION,
46
+ host: typeof r.host === "string" ? r.host : d.host,
47
+ session: { orgId: r.session?.orgId ?? null, orgName: r.session?.orgName ?? null },
48
+ current: r.current ?? null,
49
+ recent: Array.isArray(r.recent) ? r.recent.slice(0, RECENT_MAX) : [],
50
+ sdkPlatform: typeof r.sdkPlatform === "string" ? r.sdkPlatform : null,
51
+ onboarding: { step: r.onboarding?.step ?? d.onboarding.step, completedAt: r.onboarding?.completedAt ?? null },
52
+ },
53
+ };
54
+ }
55
+ /** `config.json` beside `tokens.json`: dir 0700, file 0600, atomic replace, never any secret. */
56
+ export class ConfigStore {
57
+ dir;
58
+ /** Mutations are serialized so fire-and-forget callers never lose an update to a read-modify-write race. */
59
+ chain = Promise.resolve();
60
+ constructor(dir) {
61
+ this.dir = dir;
62
+ }
63
+ enqueue(work) {
64
+ const run = this.chain.then(work, work);
65
+ this.chain = run.catch(() => undefined);
66
+ return run;
67
+ }
68
+ /** Resolves once every queued mutation has finished (tests, shutdown). */
69
+ idle() {
70
+ return this.chain.then(() => undefined);
71
+ }
72
+ get path() {
73
+ return join(this.dir, "config.json");
74
+ }
75
+ async load() {
76
+ let text;
77
+ try {
78
+ text = await readFile(this.path, "utf8");
79
+ }
80
+ catch {
81
+ return { config: defaultConfig() };
82
+ }
83
+ try {
84
+ return migrate(JSON.parse(text));
85
+ }
86
+ catch {
87
+ return { config: defaultConfig(), warning: "config.json could not be parsed — starting fresh." };
88
+ }
89
+ }
90
+ async save(config) {
91
+ assertNoCredentials(config);
92
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
93
+ const tmp = `${this.path}.${process.pid}.tmp`;
94
+ await writeFile(tmp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
95
+ await chmod(tmp, 0o600);
96
+ await rename(tmp, this.path);
97
+ }
98
+ /** Record the active selection; keeps `recent` newest-first, de-duplicated by platform + device, capped. */
99
+ setCurrent(selection, extra = {}) {
100
+ return this.enqueue(async () => {
101
+ const { config } = await this.load();
102
+ const key = recentKey(selection);
103
+ const next = {
104
+ ...config,
105
+ host: extra.host ?? config.host,
106
+ session: extra.session ?? config.session,
107
+ current: selection,
108
+ recent: [selection, ...config.recent.filter((r) => recentKey(r) !== key)].slice(0, RECENT_MAX),
109
+ };
110
+ await this.save(next);
111
+ return next;
112
+ });
113
+ }
114
+ setOnboardingStep(step) {
115
+ return this.enqueue(async () => {
116
+ const { config } = await this.load();
117
+ if (config.onboarding.step === "done" && step !== "done")
118
+ return; // never regress a finished onboarding
119
+ await this.save({ ...config, onboarding: { step, completedAt: step === "done" ? config.onboarding.completedAt ?? new Date().toISOString() : config.onboarding.completedAt } });
120
+ });
121
+ }
122
+ markOnboardingDone() {
123
+ return this.enqueue(async () => {
124
+ const { config } = await this.load();
125
+ await this.save({ ...config, onboarding: { step: "done", completedAt: config.onboarding.completedAt ?? new Date().toISOString() } });
126
+ });
127
+ }
128
+ /** Forget the config (selection, recent, onboarding). Tokens are untouched. */
129
+ resetOnboarding() {
130
+ return this.enqueue(() => rm(this.path, { force: true }));
131
+ }
132
+ }
@@ -0,0 +1,15 @@
1
+ import { fetchAllPages } from "./pagination.js";
2
+ export function mapApp(raw) {
3
+ const r = (raw ?? {});
4
+ return {
5
+ id: String(r.id),
6
+ name: typeof r.name === "string" ? r.name : "",
7
+ archived: r.archived === true,
8
+ defaultLanguage: typeof r.default_language === "string" ? r.default_language : null,
9
+ logo: typeof r.logo === "string" ? r.logo : null,
10
+ };
11
+ }
12
+ export async function listApps(session, opts = {}) {
13
+ const apps = await fetchAllPages(session, "/private/apps/", {}, mapApp);
14
+ return opts.includeArchived ? apps : apps.filter((a) => !a.archived);
15
+ }
@@ -0,0 +1,10 @@
1
+ export class ControlCenterError extends Error {
2
+ code;
3
+ status;
4
+ constructor(code, message, status) {
5
+ super(message);
6
+ this.code = code;
7
+ this.status = status;
8
+ this.name = "ControlCenterError";
9
+ }
10
+ }
@@ -0,0 +1,29 @@
1
+ import { ControlCenterError } from "./errors.js";
2
+ /** DRF `StandardPagination` page size (omaha `PAGE_SIZE`). */
3
+ export const PAGE_SIZE = 50;
4
+ /**
5
+ * Fetch every page of a paginated private endpoint.
6
+ *
7
+ * Stop rule: a page with fewer than PAGE_SIZE results is the last one. The
8
+ * paginator returns 404 for any page past the end (never an empty `results`),
9
+ * so a 404 on page ≥ 2 right after a full page means "done", not "not found".
10
+ */
11
+ export async function fetchAllPages(session, path, params, map) {
12
+ const out = [];
13
+ for (let page = 1;; page++) {
14
+ const query = new URLSearchParams({ ...params, page: String(page) }).toString();
15
+ const res = await session.authorizedFetch(`${path}?${query}`);
16
+ if (res.status === 404) {
17
+ if (page > 1)
18
+ return out;
19
+ throw new ControlCenterError("not_found", `${path} was not found on this host.`, 404);
20
+ }
21
+ if (!res.ok)
22
+ throw new ControlCenterError("http", `Request to ${path} failed (HTTP ${res.status}).`, res.status);
23
+ const body = (await res.json());
24
+ const results = Array.isArray(body.results) ? body.results : [];
25
+ out.push(...results.map(map));
26
+ if (results.length < PAGE_SIZE)
27
+ return out;
28
+ }
29
+ }
@@ -0,0 +1,28 @@
1
+ import { fetchAllPages } from "./pagination.js";
2
+ const PLATFORM_TYPE_LABELS = {
3
+ apple: "Apple",
4
+ google: "Google Play",
5
+ amazon_iap: "Amazon",
6
+ roku_pay: "Roku",
7
+ web: "Web",
8
+ };
9
+ export const platformTypeLabel = (type) => PLATFORM_TYPE_LABELS[type] ?? type;
10
+ export function mapPlatform(raw) {
11
+ const r = (raw ?? {});
12
+ const type = typeof r.type === "string" ? r.type : "";
13
+ return {
14
+ id: String(r.id),
15
+ appId: typeof r.app === "string" ? r.app : null,
16
+ name: typeof r.name === "string" ? r.name : "",
17
+ type,
18
+ typeLabel: platformTypeLabel(type),
19
+ sdkEnabled: r.sdk_enabled === true,
20
+ isConfigValid: r.is_config_valid === true,
21
+ archived: r.archived === true,
22
+ marketplaceAppId: typeof r.marketplace_app_id === "string" ? r.marketplace_app_id : null,
23
+ };
24
+ }
25
+ export async function listPlatforms(session, appId, opts = {}) {
26
+ const platforms = await fetchAllPages(session, "/private/platforms/", { app_id: appId }, mapPlatform);
27
+ return opts.includeArchived ? platforms : platforms.filter((p) => !p.archived);
28
+ }
@@ -0,0 +1,35 @@
1
+ export class ResolveError extends Error {
2
+ reason;
3
+ label;
4
+ query;
5
+ candidates;
6
+ constructor(reason, label, query, candidates) {
7
+ super(reason === "none"
8
+ ? `No ${label} matches "${query}".`
9
+ : `"${query}" matches more than one ${label}: ${candidates.join(", ")}. Use the id or a more specific name.`);
10
+ this.reason = reason;
11
+ this.label = label;
12
+ this.query = query;
13
+ this.candidates = candidates;
14
+ this.name = "ResolveError";
15
+ }
16
+ }
17
+ /** Exact id → exact name (case-insensitive) → unique case-insensitive substring. */
18
+ export function resolve(items, query, label) {
19
+ const q = query.trim();
20
+ const byId = items.find((i) => i.id === q);
21
+ if (byId)
22
+ return byId;
23
+ const lower = q.toLowerCase();
24
+ const byName = items.filter((i) => i.name.toLowerCase() === lower);
25
+ if (byName.length === 1)
26
+ return byName[0];
27
+ const bySubstring = items.filter((i) => i.name.toLowerCase().includes(lower));
28
+ if (bySubstring.length === 1)
29
+ return bySubstring[0];
30
+ if (bySubstring.length === 0)
31
+ throw new ResolveError("none", label, q, []);
32
+ throw new ResolveError("ambiguous", label, q, bySubstring.map((i) => i.name));
33
+ }
34
+ export const findApp = (apps, query) => resolve(apps, query, "app");
35
+ export const findPlatform = (platforms, query) => resolve(platforms, query, "platform");
package/dist/hosts.js ADDED
@@ -0,0 +1,13 @@
1
+ /** Control Center API hosts (the same hosts nami-cli profiles point at). */
2
+ export const CONTROL_CENTER_HOSTS = {
3
+ production: "https://app.namiml.com",
4
+ staging: "https://app-staging.namiml.com",
5
+ };
6
+ export function resolveHost(name) {
7
+ const key = (name ?? "production");
8
+ const baseUrl = CONTROL_CENTER_HOSTS[key];
9
+ if (!baseUrl) {
10
+ throw new Error(`Unknown host "${name}". Use one of: ${Object.keys(CONTROL_CENTER_HOSTS).join(", ")}.`);
11
+ }
12
+ return { name: key, baseUrl };
13
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,5 @@
1
+ import envPaths from "env-paths";
2
+ /** CLI config directory. `NAMI_CLI_HOME` overrides it (tests, CI, sandboxes). */
3
+ export function configDir(env = process.env) {
4
+ return env.NAMI_CLI_HOME || envPaths("nami", { suffix: "" }).config;
5
+ }
@@ -0,0 +1,100 @@
1
+ import { Command } from "commander";
2
+ import { NAMI_CLI_VERSION } from "./version.js";
3
+ const HOST_FLAG = "--host <name>";
4
+ const HOST_HELP = "Nami environment: production (default) or staging";
5
+ export function createProgram(h, options = {}) {
6
+ const program = new Command();
7
+ // Root options (--host/--app/--platform for bare `nami`) must only apply BEFORE a
8
+ // subcommand name; otherwise commander files `nami platforms --app X` under the
9
+ // root and the subcommand's required --app comes up missing.
10
+ program.enablePositionalOptions();
11
+ if (options.testing) {
12
+ program.exitOverride().configureOutput({ writeOut: () => { }, writeErr: () => { } });
13
+ }
14
+ program
15
+ .name("nami")
16
+ .description("Nami SDK developer CLI — a terminal test app for your Nami integration.")
17
+ .version(NAMI_CLI_VERSION, "-v, --version")
18
+ .showHelpAfterError()
19
+ .option(HOST_FLAG, HOST_HELP)
20
+ .option("--app <id-or-name>", "skip the app picker")
21
+ .option("--platform <id-or-name>", "skip the platform picker")
22
+ .action(async (_opts, cmd) => {
23
+ const opts = cmd.optsWithGlobals();
24
+ process.exitCode = await h.shell(opts);
25
+ });
26
+ program
27
+ .command("login")
28
+ .description("Sign in to the Nami Control Center (email + password, then your authenticator code if enabled).")
29
+ .option(HOST_FLAG, HOST_HELP)
30
+ .option("--totp-code <code>", "one-time authenticator code (skips the prompt; never stored)")
31
+ .option("--org <id-or-name>", "organization to select without prompting")
32
+ .option("--print-token", "print the access token after sign-in, for use as NAMI_TOKEN in CI")
33
+ .action(async (_opts, cmd) => {
34
+ process.exitCode = await h.login(cmd.optsWithGlobals());
35
+ });
36
+ program
37
+ .command("logout")
38
+ .description("Forget the stored sign-in tokens on this machine.")
39
+ .option(HOST_FLAG, HOST_HELP)
40
+ .action(async (_opts, cmd) => {
41
+ process.exitCode = await h.logout(cmd.optsWithGlobals());
42
+ });
43
+ program
44
+ .command("whoami")
45
+ .description("Show the signed-in account and organization.")
46
+ .option(HOST_FLAG, HOST_HELP)
47
+ .option("--json", "machine-readable output (never includes tokens)")
48
+ .action(async (_opts, cmd) => {
49
+ process.exitCode = await h.whoami(cmd.optsWithGlobals());
50
+ });
51
+ program
52
+ .command("apps")
53
+ .description("List the apps in your organization.")
54
+ .option(HOST_FLAG, HOST_HELP)
55
+ .option("--all", "include archived apps")
56
+ .option("--json", "machine-readable output")
57
+ .action(async (_opts, cmd) => {
58
+ process.exitCode = await h.apps(cmd.optsWithGlobals());
59
+ });
60
+ program
61
+ .command("platforms")
62
+ .description("List an app's platforms. The id column is the App Platform ID the SDK uses.")
63
+ .requiredOption("--app <id-or-name>", "app id, exact name, or unique substring")
64
+ .option(HOST_FLAG, HOST_HELP)
65
+ .option("--all", "include archived platforms")
66
+ .option("--json", "machine-readable output")
67
+ .action(async (_opts, cmd) => {
68
+ process.exitCode = await h.platforms(cmd.optsWithGlobals());
69
+ });
70
+ program
71
+ .command("configure")
72
+ .description("Verify an App Platform ID and configure the SDK against it (headless).")
73
+ .requiredOption("--app-platform-id <uuid>", "the App Platform ID (nami platforms shows it)")
74
+ .option(HOST_FLAG, HOST_HELP)
75
+ .option("--json", "machine-readable result")
76
+ .action(async (_opts, cmd) => {
77
+ process.exitCode = await h.configure(cmd.optsWithGlobals());
78
+ });
79
+ program
80
+ .command("config")
81
+ .description("Show the persisted CLI state (host, org, current app/platform, recent, onboarding). Never contains secrets.")
82
+ .option(HOST_FLAG, HOST_HELP)
83
+ .option("--json", "print config.json verbatim")
84
+ .action(async (_opts, cmd) => {
85
+ process.exitCode = await h.config(cmd.optsWithGlobals());
86
+ });
87
+ program
88
+ .command("onboarding")
89
+ .description("Show the onboarding step, or start over with --reset (keeps your sign-in).")
90
+ .option(HOST_FLAG, HOST_HELP)
91
+ .option("--reset", "forget the current selection, recent list and onboarding progress")
92
+ .action(async (_opts, cmd) => {
93
+ process.exitCode = await h.onboarding(cmd.optsWithGlobals());
94
+ });
95
+ program
96
+ .command("demo")
97
+ .description("Show the placeholder screen with the tide animation.")
98
+ .action(() => h.demo());
99
+ return program;
100
+ }