@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
package/LICENSE ADDED
@@ -0,0 +1 @@
1
+ © Nami ML Inc. Your use is subject to the terms at https://www.namiml.com/
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # Nami CLI
2
+
3
+ `nami` is the terminal test app for your Nami integration: configure the SDK against your app, list and launch placements, see your pages and flows rendered in the terminal, inspect API traffic and customer attributes, and check your implementation against what a flow expects.
4
+
5
+ ```bash
6
+ npx @namiml/cli # run without installing
7
+ npm install -g @namiml/cli # or install the `nami` command
8
+ nami --version
9
+ ```
10
+
11
+ Requires Node 20+. Single-file executables for macOS, Linux and Windows are attached to each [release](https://github.com/namiml/nami-cli/releases).
12
+
13
+ `@namiml/cli` is versioned in lockstep with the Nami SDKs — `nami --version` tells you exactly which SDK release it is pinned to.
14
+
15
+ ## Status
16
+
17
+ Early access. The current release is a scaffold; the test-app screens, onboarding and implementation checks are landing release by release. Each [GitHub release](https://github.com/namiml/nami-cli/releases) carries its notes.
18
+
19
+ ## License
20
+
21
+ © Nami ML Inc. Your use is subject to the terms at <https://www.namiml.com/>.
package/dist/App.js ADDED
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text, useApp } from "ink";
4
+ import { NAMI_SDK_VERSION } from "@namiml/sdk-core";
5
+ import { NAMI_CLI_VERSION } from "./version.js";
6
+ import { Thinking } from "./components/Thinking.js";
7
+ import { palette } from "./theme.js";
8
+ export function App({ demo = true, demoMs = 2200, phase }) {
9
+ const { exit } = useApp();
10
+ const [ready, setReady] = useState(false);
11
+ useEffect(() => {
12
+ if (!demo)
13
+ return;
14
+ const id = setTimeout(() => setReady(true), demoMs);
15
+ return () => clearTimeout(id);
16
+ }, [demo, demoMs]);
17
+ useEffect(() => {
18
+ if (demo && ready)
19
+ exit();
20
+ }, [demo, ready, exit]);
21
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Nami SDK CLI" }), _jsxs(Text, { children: ["@namiml/cli ", NAMI_CLI_VERSION] }), _jsxs(Text, { dimColor: true, children: ["pinned to @namiml/sdk-core ", NAMI_SDK_VERSION] }), _jsx(Text, { children: "Test app, onboarding and checks land in NAM-4482 sub-tickets." })] }), demo && !ready && _jsx(Thinking, { label: "Warming up the tide\u2026", phase: phase }), demo && ready && _jsx(Text, { color: palette.aqua, children: "Ready." })] }));
22
+ }
@@ -0,0 +1,87 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ const DEBOUNCE_MS = 50;
4
+ /** Storage file for one App Platform ID. Throws on path-traversal attempts. */
5
+ export function storagePathFor(appPlatformId, configDir) {
6
+ if (!/^[A-Za-z0-9-]+$/.test(appPlatformId))
7
+ throw new Error(`Refusing to build a storage path from "${appPlatformId}".`);
8
+ return join(configDir, "storage", `${appPlatformId}.json`);
9
+ }
10
+ /**
11
+ * Synchronous IStorageAdapter backed by one JSON file per App Platform ID, so
12
+ * each platform keeps its own device identity, profile and cache. Loads once,
13
+ * writes through with a short debounce, and flushes on process exit.
14
+ */
15
+ export class FileStorageAdapter {
16
+ path;
17
+ data = new Map();
18
+ timer = null;
19
+ dirty = false;
20
+ onExit = () => this.flush();
21
+ constructor(path) {
22
+ this.path = path;
23
+ try {
24
+ if (existsSync(path)) {
25
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
26
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
27
+ for (const [k, v] of Object.entries(parsed)) {
28
+ if (typeof v === "string")
29
+ this.data.set(k, v);
30
+ }
31
+ }
32
+ }
33
+ }
34
+ catch {
35
+ // Corrupt file: start empty; the next write replaces it.
36
+ }
37
+ process.once("exit", this.onExit);
38
+ }
39
+ getItem(key) {
40
+ return this.data.get(key) ?? null;
41
+ }
42
+ setItem(key, value) {
43
+ this.data.set(key, value);
44
+ this.schedule();
45
+ }
46
+ removeItem(key) {
47
+ if (this.data.delete(key))
48
+ this.schedule();
49
+ }
50
+ clear() {
51
+ this.data.clear();
52
+ this.schedule();
53
+ }
54
+ getAllKeys() {
55
+ return [...this.data.keys()];
56
+ }
57
+ /** Write pending changes now (also called on process exit). */
58
+ flush() {
59
+ if (this.timer) {
60
+ clearTimeout(this.timer);
61
+ this.timer = null;
62
+ }
63
+ if (!this.dirty)
64
+ return;
65
+ this.dirty = false;
66
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
67
+ const tmp = `${this.path}.${process.pid}.tmp`;
68
+ writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.data), null, 2) + "\n", { mode: 0o600 });
69
+ chmodSync(tmp, 0o600);
70
+ renameSync(tmp, this.path);
71
+ }
72
+ /** Stop listening for exit (tests, or when swapping platforms). Flushes first. */
73
+ dispose() {
74
+ this.flush();
75
+ process.off("exit", this.onExit);
76
+ }
77
+ schedule() {
78
+ this.dirty = true;
79
+ if (this.timer)
80
+ return;
81
+ this.timer = setTimeout(() => {
82
+ this.timer = null;
83
+ this.flush();
84
+ }, DEBOUNCE_MS);
85
+ this.timer.unref?.();
86
+ }
87
+ }
@@ -0,0 +1,18 @@
1
+ import { registerPlatformAdapters, registerPurchaseAdapter } from "@namiml/sdk-core";
2
+ import { TerminalStorageAdapter } from "./terminal-storage.adapter.js";
3
+ import { TerminalDeviceAdapter } from "./terminal-device.adapter.js";
4
+ import { TerminalUIAdapter } from "./terminal-ui.adapter.js";
5
+ import { TerminalPurchaseAdapter } from "./terminal-purchase.adapter.js";
6
+ /** Register the terminal adapter set with @namiml/sdk-core. Call before Nami.configure(). */
7
+ export function registerTerminalAdapters(options = {}) {
8
+ const adapters = {
9
+ storage: options.storage ?? new TerminalStorageAdapter(),
10
+ device: new TerminalDeviceAdapter(),
11
+ ui: new TerminalUIAdapter(),
12
+ purchase: new TerminalPurchaseAdapter(),
13
+ };
14
+ registerPlatformAdapters({ storage: adapters.storage, device: adapters.device, ui: adapters.ui });
15
+ registerPurchaseAdapter(adapters.purchase);
16
+ return adapters;
17
+ }
18
+ export { TerminalStorageAdapter, TerminalDeviceAdapter, TerminalUIAdapter, TerminalPurchaseAdapter };
@@ -0,0 +1,39 @@
1
+ import os from "node:os";
2
+ import { randomUUID } from "node:crypto";
3
+ import { NAMI_SDK_PACKAGE_VERSION } from "@namiml/sdk-core";
4
+ const SDK_CLIENT = "cli";
5
+ function terminalLanguage() {
6
+ const raw = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || "en";
7
+ const tag = raw.split(".")[0].replace("_", "-");
8
+ return !tag || tag === "C" || tag === "POSIX" ? "en" : tag;
9
+ }
10
+ export class TerminalDeviceAdapter {
11
+ getDeviceData() {
12
+ return {
13
+ os_name: os.platform(),
14
+ os_version: os.release(),
15
+ browser_name: "",
16
+ browser_version: "",
17
+ sdk_client: SDK_CLIENT,
18
+ sdk_version: NAMI_SDK_PACKAGE_VERSION,
19
+ language: this.getLanguage(),
20
+ extended_platform: "node",
21
+ extended_platform_version: process.versions.node,
22
+ };
23
+ }
24
+ getDeviceFormFactor() {
25
+ return "desktop";
26
+ }
27
+ getDeviceScaleFactor() {
28
+ return 1;
29
+ }
30
+ generateUUID() {
31
+ return randomUUID();
32
+ }
33
+ getScreenInfo() {
34
+ return { width: process.stdout.columns ?? 80, height: process.stdout.rows ?? 24, scale: 1 };
35
+ }
36
+ getLanguage() {
37
+ return terminalLanguage();
38
+ }
39
+ }
@@ -0,0 +1,12 @@
1
+ /** Simulated store: the CLI never talks to App Store / Play / Stripe. */
2
+ export class TerminalPurchaseAdapter {
3
+ async getProducts(_skuIds) {
4
+ return [];
5
+ }
6
+ async purchase(skuId, _context) {
7
+ return { success: false, skuId, message: "Purchases are simulated in the Nami CLI" };
8
+ }
9
+ async restorePurchases() {
10
+ return [];
11
+ }
12
+ }
@@ -0,0 +1,19 @@
1
+ /** In-memory storage. File-backed persistence arrives with onboarding (NAM-4482). */
2
+ export class TerminalStorageAdapter {
3
+ store = new Map();
4
+ getItem(key) {
5
+ return this.store.get(key) ?? null;
6
+ }
7
+ setItem(key, value) {
8
+ this.store.set(key, value);
9
+ }
10
+ removeItem(key) {
11
+ this.store.delete(key);
12
+ }
13
+ clear() {
14
+ this.store.clear();
15
+ }
16
+ getAllKeys() {
17
+ return [...this.store.keys()];
18
+ }
19
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Listener-style UI adapter (same shape as Expo's). Core calls createPaywall();
3
+ * the future composite renderer subscribes via onPaywallRequested().
4
+ */
5
+ export class TerminalUIAdapter {
6
+ paywallListener = null;
7
+ onPaywallRequested(listener) {
8
+ this.paywallListener = listener;
9
+ return () => {
10
+ this.paywallListener = null;
11
+ };
12
+ }
13
+ createPaywall(type, value, context) {
14
+ const request = { type, value, context };
15
+ this.paywallListener?.(request);
16
+ return request;
17
+ }
18
+ reRenderPaywall() { }
19
+ flowNavigateToScreen(_paywall, _options) { }
20
+ onReset() {
21
+ this.paywallListener = null;
22
+ }
23
+ }
@@ -0,0 +1,130 @@
1
+ import { NAMI_CLI_VERSION } from "../version.js";
2
+ import { AuthError } from "./errors.js";
3
+ const LOGIN = "/private/account/login/";
4
+ const CONFIRM_MFA = "/private/account/login/confirm_mfa/";
5
+ const TOKEN_REFRESH = "/private/account/token/refresh/";
6
+ const ACCOUNT = "/private/account/";
7
+ /**
8
+ * Omaha's two-step login (password → optional TOTP) and org-scoped token
9
+ * refresh, as consumed by nami-platform's `nami-cli`. Credentials are used for
10
+ * exactly one request and never retained or logged.
11
+ */
12
+ export class ControlCenterAuth {
13
+ baseUrl;
14
+ fetchImpl;
15
+ userAgent;
16
+ constructor(baseUrl, fetchImpl = globalThis.fetch, userAgent = `@namiml/cli/${NAMI_CLI_VERSION}`) {
17
+ this.baseUrl = baseUrl;
18
+ this.fetchImpl = fetchImpl;
19
+ this.userAgent = userAgent;
20
+ }
21
+ async login(username, password) {
22
+ const res = await this.request("POST", LOGIN, { username, password });
23
+ if (res.status === 429)
24
+ throw throttled(res.status);
25
+ if (res.status === 400 || res.status === 401 || res.status === 403) {
26
+ throw new AuthError("invalid_credentials", "Email or password is incorrect.", res.status);
27
+ }
28
+ if (!res.ok)
29
+ throw httpError("Sign-in", res.status);
30
+ const data = (await res.json());
31
+ if (typeof data.pre_auth_token === "string") {
32
+ return {
33
+ kind: "mfa_required",
34
+ preAuthToken: data.pre_auth_token,
35
+ authenticators: Array.isArray(data.authenticators) ? data.authenticators : [],
36
+ };
37
+ }
38
+ return signedIn(data);
39
+ }
40
+ async confirmMfa(preAuthToken, code, type = "totp") {
41
+ const res = await this.request("POST", CONFIRM_MFA, { code, type }, { authorization: `Bearer ${preAuthToken}` });
42
+ if (res.status === 429)
43
+ throw throttled(res.status);
44
+ if (res.status === 400 || res.status === 401 || res.status === 403) {
45
+ throw new AuthError("mfa_invalid", "That code was not accepted. Check your authenticator app and try again.", res.status);
46
+ }
47
+ if (!res.ok)
48
+ throw httpError("Two-factor confirmation", res.status);
49
+ return signedIn((await res.json()));
50
+ }
51
+ /** Exchange the refresh token for an access token scoped to `orgId` (201). */
52
+ async selectOrg(refreshToken, orgId) {
53
+ const res = await this.request("POST", TOKEN_REFRESH, { refresh_token: refreshToken, selected_org: orgId });
54
+ if (res.status === 429)
55
+ throw throttled(res.status);
56
+ if (res.status === 401 || res.status === 403) {
57
+ const detail = await detailOf(res);
58
+ if (/org/i.test(detail)) {
59
+ throw new AuthError("org_forbidden", "Your account does not have access to that organization.", res.status);
60
+ }
61
+ throw new AuthError("session_expired", "Your session has expired. Run `nami login` again.", res.status);
62
+ }
63
+ if (!res.ok)
64
+ throw httpError("Token refresh", res.status);
65
+ const data = (await res.json());
66
+ if (typeof data.access_token !== "string")
67
+ throw httpError("Token refresh", res.status);
68
+ return data.access_token;
69
+ }
70
+ async fetchAccount(accessToken) {
71
+ const res = await this.request("GET", ACCOUNT, undefined, { authorization: `Bearer ${accessToken}` });
72
+ if (res.status === 401 || res.status === 403) {
73
+ throw new AuthError("session_expired", "Your session has expired. Run `nami login` again.", res.status);
74
+ }
75
+ if (!res.ok)
76
+ throw httpError("Account lookup", res.status);
77
+ return toProfile((await res.json()));
78
+ }
79
+ async request(method, path, body, headers = {}) {
80
+ try {
81
+ return await this.fetchImpl(this.baseUrl + path, {
82
+ method,
83
+ headers: {
84
+ accept: "application/json",
85
+ "user-agent": this.userAgent,
86
+ ...(body ? { "content-type": "application/json" } : {}),
87
+ ...headers,
88
+ },
89
+ body: body ? JSON.stringify(body) : undefined,
90
+ });
91
+ }
92
+ catch {
93
+ throw new AuthError("network", `Could not reach ${this.baseUrl}. Check your connection and the host.`);
94
+ }
95
+ }
96
+ }
97
+ function signedIn(data) {
98
+ if (typeof data.access_token !== "string" || typeof data.refresh_token !== "string") {
99
+ throw new AuthError("http", "Sign-in response did not include tokens.");
100
+ }
101
+ return {
102
+ kind: "signed_in",
103
+ tokens: { accessToken: data.access_token, refreshToken: data.refresh_token },
104
+ profile: toProfile(data.profile ?? {}),
105
+ };
106
+ }
107
+ function toProfile(profile) {
108
+ const orgs = Array.isArray(profile.orgs)
109
+ ? profile.orgs.map((o) => ({ id: String(o.id), name: String(o.name) }))
110
+ : [];
111
+ const details = profile.default_org_member_details;
112
+ const defaultOrg = details?.org;
113
+ return {
114
+ id: typeof profile.id === "string" ? profile.id : undefined,
115
+ username: typeof profile.username === "string" ? profile.username : undefined,
116
+ orgs,
117
+ defaultOrgId: typeof defaultOrg?.id === "string" ? defaultOrg.id : null,
118
+ };
119
+ }
120
+ async function detailOf(res) {
121
+ try {
122
+ const data = (await res.json());
123
+ return typeof data.detail === "string" ? data.detail : "";
124
+ }
125
+ catch {
126
+ return "";
127
+ }
128
+ }
129
+ const throttled = (status) => new AuthError("throttled", "Too many sign-in attempts. Wait a minute and try again.", status);
130
+ const httpError = (what, status) => new AuthError("http", `${what} failed (HTTP ${status}).`, status);
@@ -0,0 +1,10 @@
1
+ export class AuthError 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 = "AuthError";
9
+ }
10
+ }
@@ -0,0 +1,11 @@
1
+ /** Mask credentials in headers before they are rendered anywhere (logs, traffic tab). */
2
+ export function redactAuthorization(headers) {
3
+ const out = { ...headers };
4
+ for (const key of Object.keys(out)) {
5
+ if (key.toLowerCase() === "authorization") {
6
+ const scheme = out[key].split(" ")[0];
7
+ out[key] = `${scheme} ***`;
8
+ }
9
+ }
10
+ return out;
11
+ }
@@ -0,0 +1,56 @@
1
+ import { AuthError } from "./errors.js";
2
+ const NOT_SIGNED_IN = "Not signed in. Run `nami login` first.";
3
+ const EXPIRED = "Your session has expired. Run `nami login` again.";
4
+ /**
5
+ * Authenticated access to the Control Center API. Uses `NAMI_TOKEN` when set
6
+ * (headless / CI — never persisted), otherwise the stored session, refreshing
7
+ * the org-scoped access token once on 401.
8
+ */
9
+ export class AuthSession {
10
+ store;
11
+ auth;
12
+ baseUrl;
13
+ fetchImpl;
14
+ env;
15
+ constructor(store, auth,
16
+ /** Control Center base URL; paths passed to authorizedFetch are resolved against it. */
17
+ baseUrl, fetchImpl = globalThis.fetch, env = process.env) {
18
+ this.store = store;
19
+ this.auth = auth;
20
+ this.baseUrl = baseUrl;
21
+ this.fetchImpl = fetchImpl;
22
+ this.env = env;
23
+ }
24
+ current() {
25
+ return this.store.load();
26
+ }
27
+ /** `pathOrUrl` may be an absolute URL or a `/private/...` path. */
28
+ async authorizedFetch(pathOrUrl, init = {}) {
29
+ const url = pathOrUrl.startsWith("/") ? this.baseUrl.replace(/\/$/, "") + pathOrUrl : pathOrUrl;
30
+ const envToken = this.env.NAMI_TOKEN;
31
+ if (envToken)
32
+ return this.withBearer(url, init, envToken);
33
+ const session = await this.store.load();
34
+ if (!session)
35
+ throw new AuthError("session_expired", NOT_SIGNED_IN);
36
+ const first = await this.withBearer(url, init, session.accessToken);
37
+ if (first.status !== 401)
38
+ return first;
39
+ let fresh;
40
+ try {
41
+ fresh = await this.auth.selectOrg(session.refreshToken, session.orgId);
42
+ }
43
+ catch {
44
+ throw new AuthError("session_expired", EXPIRED, 401);
45
+ }
46
+ await this.store.save({ ...session, accessToken: fresh, savedAt: new Date().toISOString() });
47
+ const retry = await this.withBearer(url, init, fresh);
48
+ if (retry.status === 401)
49
+ throw new AuthError("session_expired", EXPIRED, 401);
50
+ return retry;
51
+ }
52
+ withBearer(url, init, token) {
53
+ const headers = { accept: "application/json", ...init.headers, authorization: `Bearer ${token}` };
54
+ return this.fetchImpl(url, { ...init, headers });
55
+ }
56
+ }
@@ -0,0 +1,36 @@
1
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ const isStoredSession = (value) => typeof value === "object" &&
4
+ value !== null &&
5
+ typeof value.accessToken === "string" &&
6
+ typeof value.refreshToken === "string" &&
7
+ typeof value.baseUrl === "string";
8
+ /** `tokens.json` under the config dir: directory 0700, file 0600, atomic replace. */
9
+ export class FileTokenStore {
10
+ dir;
11
+ constructor(dir) {
12
+ this.dir = dir;
13
+ }
14
+ get path() {
15
+ return join(this.dir, "tokens.json");
16
+ }
17
+ async load() {
18
+ try {
19
+ const parsed = JSON.parse(await readFile(this.path, "utf8"));
20
+ return isStoredSession(parsed) ? parsed : null;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ async save(session) {
27
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
28
+ const tmp = `${this.path}.${process.pid}.tmp`;
29
+ await writeFile(tmp, JSON.stringify(session, null, 2) + "\n", { mode: 0o600 });
30
+ await chmod(tmp, 0o600);
31
+ await rename(tmp, this.path);
32
+ }
33
+ async clear() {
34
+ await rm(this.path, { force: true });
35
+ }
36
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from "ink";
4
+ import { App } from "./App.js";
5
+ import { createProgram } from "./program.js";
6
+ import { buildContext } from "./commands/context.js";
7
+ import { login } from "./commands/login.js";
8
+ import { logout } from "./commands/logout.js";
9
+ import { whoami } from "./commands/whoami.js";
10
+ import { apps } from "./commands/apps.js";
11
+ import { platforms } from "./commands/platforms.js";
12
+ import { shell } from "./commands/shell.js";
13
+ import { configure } from "./commands/configure.js";
14
+ import { config } from "./commands/config.js";
15
+ import { onboarding } from "./commands/onboarding.js";
16
+ const program = createProgram({
17
+ shell: async (opts) => shell(opts, await buildContext(opts.host)),
18
+ demo: () => { render(_jsx(App, {})); },
19
+ login: async (opts) => login(opts, await buildContext(opts.host)),
20
+ logout: async (opts) => logout(await buildContext(opts.host)),
21
+ whoami: async (opts) => whoami(opts, await buildContext(opts.host)),
22
+ apps: async (opts) => apps(opts, await buildContext(opts.host)),
23
+ platforms: async (opts) => platforms(opts, await buildContext(opts.host)),
24
+ configure: async (opts) => configure(opts, await buildContext(opts.host)),
25
+ config: async (opts) => config(opts, await buildContext(opts.host)),
26
+ onboarding: async (opts) => onboarding(opts, await buildContext(opts.host)),
27
+ });
28
+ await program.parseAsync(process.argv);
@@ -0,0 +1,26 @@
1
+ import { AuthError } from "../auth/errors.js";
2
+ import { listApps } from "../control-center/apps.js";
3
+ import { renderTable } from "./list-output.js";
4
+ /** `nami apps` — list the signed-in org's apps. Returns the exit code. */
5
+ export async function apps(opts, ctx) {
6
+ try {
7
+ const items = await listApps(ctx.session, { includeArchived: Boolean(opts.all) });
8
+ if (opts.json) {
9
+ ctx.out(JSON.stringify(items, null, 2));
10
+ }
11
+ else if (items.length === 0) {
12
+ ctx.out(`No apps found on ${ctx.host.name}. Create one in the Control Center (${ctx.host.baseUrl}).`);
13
+ }
14
+ else {
15
+ ctx.out(renderTable(items, ["id", "name", "defaultLanguage", "archived"]));
16
+ }
17
+ return 0;
18
+ }
19
+ catch (error) {
20
+ if (error instanceof AuthError) {
21
+ ctx.err(error.message);
22
+ return 1;
23
+ }
24
+ throw error;
25
+ }
26
+ }
@@ -0,0 +1,20 @@
1
+ const sel = (s) => `${s.appName} · ${s.platformName} (${s.platformType})${s.device ? ` · ${s.device.displayName}` : ""} ${s.appPlatformId}`;
2
+ /** `nami config` — show the persisted, secret-free state. Returns the exit code. */
3
+ export async function config(opts, ctx) {
4
+ const { config, warning } = await ctx.config.load();
5
+ if (warning)
6
+ ctx.err(warning);
7
+ if (opts.json) {
8
+ ctx.out(JSON.stringify(config, null, 2));
9
+ return 0;
10
+ }
11
+ ctx.out(`Config: ${ctx.config.path}`);
12
+ ctx.out(`Host: ${config.host}`);
13
+ ctx.out(`Org: ${config.session.orgName ?? "—"}${config.session.orgId ? ` (${config.session.orgId})` : ""}`);
14
+ ctx.out(`Current: ${config.current ? sel(config.current) : "— (run nami to pick an app and platform)"}`);
15
+ ctx.out(`Recent: ${config.recent.length === 0 ? "—" : ""}`);
16
+ for (const r of config.recent)
17
+ ctx.out(` ${sel(r)}`);
18
+ ctx.out(`Onboarding: ${config.onboarding.step}${config.onboarding.completedAt ? ` (completed ${config.onboarding.completedAt})` : ""}`);
19
+ return 0;
20
+ }
@@ -0,0 +1,29 @@
1
+ import { configureSdk } from "../sdk/configure.js";
2
+ import { SDK_DISABLED_MESSAGE, VerifyError, verifyAppPlatform } from "../sdk/verify.js";
3
+ /** `nami configure --app-platform-id <uuid>` — headless verify + configure. Returns the exit code. */
4
+ export async function configure(opts, ctx, deps = {}) {
5
+ const verify = deps.verify ?? verifyAppPlatform;
6
+ const run = deps.configure ?? configureSdk;
7
+ let result;
8
+ try {
9
+ const verified = await verify(ctx.host.baseUrl, opts.appPlatformId, deps.fetch ?? globalThis.fetch);
10
+ if (verified.kind === "sdk_disabled")
11
+ ctx.err(SDK_DISABLED_MESSAGE);
12
+ result = await run({ host: ctx.host, appPlatformId: opts.appPlatformId }, { ...deps, verified });
13
+ }
14
+ catch (error) {
15
+ if (error instanceof VerifyError) {
16
+ ctx.err(error.message);
17
+ return error.code === "invalid_id" ? 2 : 1;
18
+ }
19
+ throw error;
20
+ }
21
+ if (opts.json) {
22
+ ctx.out(JSON.stringify(result, null, 2));
23
+ }
24
+ else {
25
+ ctx.out(`Configured ${result.appPlatformId} on ${result.host}: ${result.configureState}${result.sdkEnabled ? "" : " (SDK disabled)"}`);
26
+ ctx.out(`placements ${result.counts.placements} · products ${result.counts.products} · paywalls ${result.counts.paywalls}${result.deviceId ? ` · device ${result.deviceId}` : ""}`);
27
+ }
28
+ return result.sdkInitialized ? 0 : 1;
29
+ }
@@ -0,0 +1,26 @@
1
+ import { ControlCenterAuth } from "../auth/control-center.js";
2
+ import { AuthSession } from "../auth/session.js";
3
+ import { FileTokenStore } from "../auth/token-store.js";
4
+ import { resolveHost } from "../hosts.js";
5
+ import { configDir } from "../paths.js";
6
+ import { ConfigStore } from "../config/store.js";
7
+ export async function buildContext(hostFlag, env = process.env) {
8
+ const dir = configDir(env);
9
+ const store = new FileTokenStore(dir);
10
+ const config = new ConfigStore(dir);
11
+ const stored = await store.load();
12
+ // Without an explicit --host, follow the stored session, then the persisted config.
13
+ const host = resolveHost(hostFlag ?? stored?.host ?? (await config.load()).config.host);
14
+ const auth = new ControlCenterAuth(host.baseUrl);
15
+ return {
16
+ host,
17
+ store,
18
+ config,
19
+ auth,
20
+ session: new AuthSession(store, auth, host.baseUrl, globalThis.fetch, env),
21
+ env,
22
+ out: (line) => process.stdout.write(line + "\n"),
23
+ err: (line) => process.stderr.write(line + "\n"),
24
+ isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
25
+ };
26
+ }
@@ -0,0 +1,9 @@
1
+ /** Fixed-width text table for list commands. Values are stringified; columns sized to content. */
2
+ export function renderTable(rows, columns) {
3
+ if (rows.length === 0)
4
+ return "";
5
+ const cells = rows.map((r) => columns.map((c) => String(r[c] ?? "")));
6
+ const widths = columns.map((c, i) => Math.max(c.length, ...cells.map((row) => row[i].length)));
7
+ const line = (vals) => vals.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
8
+ return [line(columns), line(widths.map((w) => "-".repeat(w))), ...cells.map(line)].join("\n");
9
+ }