@namiml/cli 3.4.11-dev.202609230009 → 3.4.11-dev.202609231629

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/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ import { use } from "./commands/use.js";
15
15
  import { config } from "./commands/config.js";
16
16
  import { onboarding } from "./commands/onboarding.js";
17
17
  import { devices } from "./commands/devices.js";
18
+ import { placements } from "./commands/placements.js";
18
19
  const program = createProgram({
19
20
  shell: async (opts) => shell(opts, await buildContext(opts.host)),
20
21
  demo: () => { render(_jsx(App, {})); },
@@ -28,5 +29,6 @@ const program = createProgram({
28
29
  config: async (opts) => config(opts, await buildContext(opts.host)),
29
30
  onboarding: async (opts) => onboarding(opts, await buildContext(opts.host)),
30
31
  devices: async (opts) => devices(opts, await buildContext(opts.host)),
32
+ placements: async (opts) => placements(opts, await buildContext(opts.host)),
31
33
  });
32
34
  await program.parseAsync(process.argv);
@@ -1,5 +1,6 @@
1
1
  import { configureSdk } from "../sdk/configure.js";
2
2
  import { SDK_DISABLED_MESSAGE, VerifyError, verifyAppPlatform } from "../sdk/verify.js";
3
+ import { configureSummaryLines } from "../sdk/summary.js";
3
4
  /** `nami configure --app-platform-id <uuid>` — headless verify + configure. Returns the exit code. */
4
5
  export async function configure(opts, ctx, deps = {}) {
5
6
  const verify = deps.verify ?? verifyAppPlatform;
@@ -27,8 +28,8 @@ export async function configure(opts, ctx, deps = {}) {
27
28
  ctx.out(JSON.stringify(result, null, 2));
28
29
  }
29
30
  else {
30
- ctx.out(`Configured ${result.appPlatformId} on ${result.host}: ${result.configureState}${result.sdkEnabled ? "" : " (SDK disabled)"}${result.device ? ` · ${result.device.displayName}` : ""}`);
31
- ctx.out(`placements ${result.counts.placements} · products ${result.counts.products} · paywalls ${result.counts.paywalls}${result.deviceId ? ` · device ${result.deviceId}` : ""}`);
31
+ for (const line of configureSummaryLines(result))
32
+ ctx.out(line);
32
33
  }
33
34
  return result.sdkInitialized ? 0 : 1;
34
35
  }
@@ -1,10 +1,20 @@
1
- /** `nami onboarding [--reset]` — show or reset the onboarding state. Tokens are never touched. */
2
- export async function onboarding(opts, ctx) {
1
+ import { runOnboarding } from "../onboarding/run.js";
2
+ /** The flags that switch `nami onboarding` from a status print to a headless run (`--host` is global, so it does not). */
3
+ const HEADLESS_FLAGS = ["org", "app", "platform", "appPlatformId", "fromSdkconfig", "device", "yes", "json"];
4
+ const isHeadless = (opts) => HEADLESS_FLAGS.some((flag) => opts[flag] !== undefined);
5
+ /**
6
+ * `nami onboarding` — show the onboarding state, `--reset` it, or run the whole
7
+ * thing headlessly when any of the named flags is passed (see `runOnboarding`).
8
+ * Tokens are never touched.
9
+ */
10
+ export async function onboarding(opts, ctx, deps = {}) {
3
11
  if (opts.reset) {
4
12
  await ctx.config.resetOnboarding();
5
13
  ctx.out("Onboarding reset — run nami to pick an app and platform again. Your sign-in was kept (nami logout removes it).");
6
14
  return 0;
7
15
  }
16
+ if (isHeadless(opts))
17
+ return runOnboarding(opts, ctx, deps);
8
18
  const { config } = await ctx.config.load();
9
19
  ctx.out(`Onboarding step: ${config.onboarding.step}${config.onboarding.completedAt ? ` (completed ${config.onboarding.completedAt})` : ""}`);
10
20
  if (config.onboarding.step !== "done")
@@ -0,0 +1,56 @@
1
+ import { corePlacementData, listPlacements } from "../placements/list.js";
2
+ import { configureSdk } from "../sdk/configure.js";
3
+ import { VerifyError } from "../sdk/verify.js";
4
+ import { renderTable } from "./list-output.js";
5
+ const NOT_CONFIGURED = "Nothing is configured yet. Run nami to pick an app and platform, or nami configure --app-platform-id <uuid> to name one directly.";
6
+ /**
7
+ * The real source: configure the SDK against the persisted selection, then read
8
+ * `NamiCampaignManager.allCampaigns()`. Paywalls come along because a campaign
9
+ * rule names its paywall by id only (see `listPlacements`).
10
+ */
11
+ const configuredSource = async (selection, host) => {
12
+ await configureSdk({
13
+ host,
14
+ appPlatformId: selection.appPlatformId,
15
+ device: { platformType: selection.platformType, id: selection.device?.id },
16
+ });
17
+ return corePlacementData();
18
+ };
19
+ /**
20
+ * `nami placements [--json]` — the placements the configured SDK can launch.
21
+ *
22
+ * Every `nami` run is a fresh process, so "the SDK is configured" means
23
+ * `config.json` names an app platform to configure against. Without one there is
24
+ * nothing to read, and the command says what to run instead. Returns the exit code.
25
+ */
26
+ export async function placements(opts, ctx, deps = {}) {
27
+ const { config, warning } = await ctx.config.load();
28
+ if (warning)
29
+ ctx.err(warning);
30
+ if (!config.current) {
31
+ ctx.err(NOT_CONFIGURED);
32
+ return 2;
33
+ }
34
+ let rows;
35
+ try {
36
+ const { campaigns, paywalls } = await (deps.source ?? configuredSource)(config.current, ctx.host);
37
+ rows = listPlacements(campaigns, paywalls);
38
+ }
39
+ catch (error) {
40
+ if (error instanceof VerifyError) {
41
+ ctx.err(error.message);
42
+ return error.code === "invalid_id" ? 2 : 1;
43
+ }
44
+ throw error;
45
+ }
46
+ if (opts.json) {
47
+ ctx.out(JSON.stringify(rows, null, 2));
48
+ return 0;
49
+ }
50
+ if (rows.length === 0) {
51
+ ctx.out("No placements — add a campaign in the Control Center, then run nami placements again.");
52
+ return 0;
53
+ }
54
+ ctx.out(renderTable(rows.map((p) => ({ placement: p.label, kind: p.kind, campaign: p.campaignName ?? "", paywall: p.paywallName ?? "" })), ["placement", "kind", "campaign", "paywall"]));
55
+ return 0;
56
+ }
@@ -0,0 +1,69 @@
1
+ import { readFile } from "node:fs/promises";
2
+ /**
3
+ * `nami_host` → the CLI's host, or `null` when this CLI cannot express it.
4
+ *
5
+ * Derived from `sdkconfig.schema.json` at the repo root
6
+ * (`$defs.connection.nami_host.x-ui-host-urls`): staging is
7
+ * `https://app-staging.namiml.com` and uspod1 is `https://app.namiml.com`, which are
8
+ * exactly the two `CONTROL_CENTER_HOSTS`. The rest point at base URLs a
9
+ * `ResolvedHost` has no name for, so they map to `null` rather than widening it.
10
+ */
11
+ export const SDKCONFIG_HOSTS = {
12
+ staging: "staging",
13
+ uspod1: "production",
14
+ uspod2: null,
15
+ localhost: null,
16
+ synchronoss: null,
17
+ custom: null,
18
+ };
19
+ /** The schema's own default for `nami_host`. */
20
+ const DEFAULT_SDKCONFIG_HOST = "staging";
21
+ export class SdkConfigError extends Error {
22
+ code;
23
+ constructor(code, message) {
24
+ super(message);
25
+ this.code = code;
26
+ this.name = "SdkConfigError";
27
+ }
28
+ }
29
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
30
+ const trimmed = (value) => (typeof value === "string" ? value.trim() : "");
31
+ /**
32
+ * Read the `connection` block of a test app's `sdkconfig.json`. Every failure is an
33
+ * `SdkConfigError` whose message names the file and one thing to do next — the caller
34
+ * renders it verbatim.
35
+ */
36
+ export function parseSdkConfig(text, path) {
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(text);
40
+ }
41
+ catch {
42
+ throw new SdkConfigError("unreadable", `${path} is not valid JSON. Fix the file, or paste the App Platform ID instead.`);
43
+ }
44
+ const connection = isRecord(parsed) ? parsed.connection : undefined;
45
+ if (!isRecord(connection)) {
46
+ throw new SdkConfigError("no_connection", `${path} has no "connection" block, so there is no App Platform ID to read. Point at the test app's sdkconfig.json, or paste the App Platform ID instead.`);
47
+ }
48
+ const appPlatformId = trimmed(connection.custom_app_platform_id) || trimmed(connection.app_platform_id);
49
+ if (!appPlatformId) {
50
+ throw new SdkConfigError("no_id", `The "connection" block in ${path} has no app_platform_id. Pick an app platform in the test app's settings and save, or paste the App Platform ID instead.`);
51
+ }
52
+ const namiHost = trimmed(connection.nami_host) || DEFAULT_SDKCONFIG_HOST;
53
+ const host = SDKCONFIG_HOSTS[namiHost] ?? null;
54
+ if (!host) {
55
+ throw new SdkConfigError("unsupported_host", `${path} is set to the "${namiHost}" host; this CLI only talks to production and staging. Switch the test app to staging or uspod1 (production), or paste an App Platform ID on one of those hosts.`);
56
+ }
57
+ return { appPlatformId, host };
58
+ }
59
+ /** `parseSdkConfig` over a file; an unreadable path is an `unreadable` error, never a throw from fs. */
60
+ export async function readSdkConfig(path) {
61
+ let text;
62
+ try {
63
+ text = await readFile(path, "utf8");
64
+ }
65
+ catch {
66
+ throw new SdkConfigError("unreadable", `Could not read ${path}. Check the path to the test app's sdkconfig.json, or paste the App Platform ID instead.`);
67
+ }
68
+ return parseSdkConfig(text, path);
69
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The recorder behind the Events pane.
3
+ *
4
+ * Everything the SDK emits while a placement is on screen lands here: paywall
5
+ * actions, flow events and handoffs. The bus is deliberately plain — no React,
6
+ * no `@namiml/sdk-core` import — so the adapters can feed it, the screens can
7
+ * read it and the tests can drive it without any of them dragging the others in.
8
+ */
9
+ /**
10
+ * How many events a session keeps. A long paywall session emits thousands;
11
+ * the pane only ever shows the recent tail, so the oldest are dropped first.
12
+ */
13
+ export const EVENT_BUFFER_MAX = 500;
14
+ export class EventBus {
15
+ #now;
16
+ #buffer = [];
17
+ #subscribers = new Set();
18
+ #seq = 0;
19
+ constructor({ now = () => new Date() } = {}) {
20
+ this.#now = now;
21
+ }
22
+ /** Stamp, store and fan out one event. Never throws on a subscriber's behalf. */
23
+ record(input) {
24
+ this.#seq += 1;
25
+ const event = { ...input, seq: this.#seq, at: this.#now().toISOString() };
26
+ this.#buffer.push(event);
27
+ while (this.#buffer.length > EVENT_BUFFER_MAX)
28
+ this.#buffer.shift();
29
+ for (const subscriber of [...this.#subscribers]) {
30
+ try {
31
+ subscriber(event);
32
+ }
33
+ catch {
34
+ // A broken listener is not allowed to stop the others, drop the event or
35
+ // take down the render loop. There is no log sink in a TUI to report to.
36
+ }
37
+ }
38
+ return event;
39
+ }
40
+ /** A copy, oldest first — callers cannot reach into the buffer. */
41
+ all() {
42
+ return [...this.#buffer];
43
+ }
44
+ /** Empty the buffer. `seq` keeps climbing: it identifies events, it is not a count. */
45
+ clear() {
46
+ this.#buffer.length = 0;
47
+ }
48
+ subscribe(fn) {
49
+ this.#subscribers.add(fn);
50
+ return () => {
51
+ this.#subscribers.delete(fn);
52
+ };
53
+ }
54
+ /** One JSON object per line, carrying the SDK's own field names verbatim. */
55
+ toJsonl() {
56
+ return this.#buffer.map((event) => JSON.stringify(event)).join("\n");
57
+ }
58
+ }
@@ -0,0 +1,146 @@
1
+ import { AuthError } from "../auth/errors.js";
2
+ import { appFrom, platformFrom, selectionFrom } from "../config/selection.js";
3
+ import { readSdkConfig, SdkConfigError } from "../config/sdkconfig.js";
4
+ import { listApps } from "../control-center/apps.js";
5
+ import { findAppPlatformById, pastedStandIn } from "../control-center/lookup.js";
6
+ import { configureSummaryLines } from "../sdk/summary.js";
7
+ import { listPlatforms } from "../control-center/platforms.js";
8
+ import { findApp, findPlatform, resolve, ResolveError } from "../control-center/resolve.js";
9
+ import { resolveDevice, toSelection } from "../devices/presets.js";
10
+ import { resolveHost } from "../hosts.js";
11
+ import { configureSdk } from "../sdk/configure.js";
12
+ import { SDK_DISABLED_MESSAGE, VerifyError, verifyAppPlatform } from "../sdk/verify.js";
13
+ const NOT_SIGNED_IN = "Not signed in. Run `nami login` first.";
14
+ const NO_APP_PLATFORM = "No app platform to configure. Pass --app-platform-id <uuid>, or --from-sdkconfig <path>, or --app with --platform.";
15
+ const SDK_DISABLED_NEEDS_YES = "Pass --yes to configure against it anyway.";
16
+ /**
17
+ * Run the onboarding steps with no TTY: resolve an app platform from named
18
+ * flags, configure the SDK against it and record it as the current selection.
19
+ *
20
+ * Every step is a flag, never a prompt — a missing one exits 2 naming the flag
21
+ * that would supply it, so a script (or a Claude Code skill) never blocks on a
22
+ * question it cannot answer. Auth is whatever `AuthSession` already has:
23
+ * `NAMI_TOKEN` in CI, the stored sign-in otherwise. There is deliberately no
24
+ * credential flag or env var (NAM-4487).
25
+ *
26
+ * Returns the process exit code.
27
+ */
28
+ export async function runOnboarding(input, ctx, deps = {}) {
29
+ const verify = deps.verify ?? verifyAppPlatform;
30
+ const run = deps.configure ?? configureSdk;
31
+ const doListApps = deps.listApps ?? listApps;
32
+ const doListPlatforms = deps.listPlatforms ?? listPlatforms;
33
+ const doReadSdkConfig = deps.readSdkConfig ?? readSdkConfig;
34
+ let host = ctx.host;
35
+ let appPlatformId = input.appPlatformId;
36
+ try {
37
+ if (input.fromSdkconfig) {
38
+ // The file is the fallback for both: an explicit flag on the command line wins.
39
+ const connection = await doReadSdkConfig(input.fromSdkconfig);
40
+ appPlatformId ??= connection.appPlatformId;
41
+ if (!input.host)
42
+ host = resolveHost(connection.host);
43
+ }
44
+ if (input.org) {
45
+ const stored = await ctx.store.load();
46
+ if (!stored) {
47
+ ctx.err(NOT_SIGNED_IN);
48
+ return 1;
49
+ }
50
+ const profile = await ctx.auth.fetchAccount(stored.accessToken);
51
+ const org = resolve(profile.orgs, input.org, "organization");
52
+ const accessToken = await ctx.auth.selectOrg(stored.refreshToken, org.id);
53
+ const next = { ...stored, accessToken, orgId: org.id, orgName: org.name, savedAt: new Date().toISOString() };
54
+ await ctx.store.save(next);
55
+ await ctx.config.setSession({ orgId: org.id, orgName: org.name });
56
+ }
57
+ let app;
58
+ let platform;
59
+ let verified;
60
+ if (appPlatformId) {
61
+ verified = await verify(host.baseUrl, appPlatformId, globalThis.fetch);
62
+ // The id is verified on a public endpoint; naming it needs a session, so a
63
+ // failed lookup degrades to the stand-ins instead of refusing a valid id.
64
+ const named = (await findAppPlatformById(ctx.session, appPlatformId, { listApps: doListApps, listPlatforms: doListPlatforms })) ?? pastedStandIn(appPlatformId);
65
+ app = named.app;
66
+ platform = named.platform;
67
+ }
68
+ else if (input.app) {
69
+ app = findApp(await doListApps(ctx.session), input.app);
70
+ const platforms = await doListPlatforms(ctx.session, app.id);
71
+ if (input.platform) {
72
+ platform = findPlatform(platforms, input.platform);
73
+ }
74
+ else if (platforms.length === 1) {
75
+ platform = platforms[0];
76
+ }
77
+ else if (platforms.length === 0) {
78
+ ctx.err(`"${app.name}" has no platforms yet. Add one in the Control Center (${host.baseUrl}), then run nami onboarding again.`);
79
+ return 2;
80
+ }
81
+ else {
82
+ ctx.err(`"${app.name}" has more than one platform: ${platforms.map((p) => p.name).join(", ")}. Pass --platform too.`);
83
+ return 2;
84
+ }
85
+ }
86
+ else {
87
+ // --platform or --device alone target whatever `nami` last configured, the
88
+ // same way `nami use` does — keep the two surfaces in step.
89
+ const { config } = await ctx.config.load();
90
+ if (config.current && (input.platform || input.device)) {
91
+ app = appFrom(config.current);
92
+ platform = input.platform ? findPlatform(await doListPlatforms(ctx.session, app.id), input.platform) : platformFrom(config.current);
93
+ }
94
+ }
95
+ if (!app || !platform) {
96
+ ctx.err(NO_APP_PLATFORM);
97
+ return 2;
98
+ }
99
+ verified ??= await verify(host.baseUrl, platform.id, globalThis.fetch);
100
+ if (verified.kind === "sdk_disabled") {
101
+ ctx.err(SDK_DISABLED_MESSAGE);
102
+ // The interactive flow asks before continuing here; headless, --yes is that answer.
103
+ if (!input.yes) {
104
+ ctx.err(SDK_DISABLED_NEEDS_YES);
105
+ return 2;
106
+ }
107
+ }
108
+ let device = null;
109
+ if (input.device)
110
+ device = toSelection(resolveDevice(platform.type, input.device));
111
+ const result = await run({ host, appPlatformId: platform.id, device: { platformType: platform.type, id: input.device } }, { verified });
112
+ await ctx.config.setCurrent(selectionFrom(app, platform, device ?? result.device), { host: host.name });
113
+ await ctx.config.markOnboardingDone();
114
+ report(result, input, ctx);
115
+ return result.sdkInitialized ? 0 : 1;
116
+ }
117
+ catch (error) {
118
+ if (error instanceof SdkConfigError) {
119
+ // The message already names the file and one thing to do next.
120
+ ctx.err(error.message);
121
+ return 2;
122
+ }
123
+ if (error instanceof ResolveError) {
124
+ ctx.err(error.message);
125
+ return 2;
126
+ }
127
+ if (error instanceof VerifyError) {
128
+ ctx.err(error.message);
129
+ return error.code === "invalid_id" ? 2 : 1;
130
+ }
131
+ if (error instanceof AuthError) {
132
+ ctx.err(error.message);
133
+ return 1;
134
+ }
135
+ throw error;
136
+ }
137
+ }
138
+ /** The same two lines `nami configure` prints, or the raw result under --json. */
139
+ function report(result, input, ctx) {
140
+ if (input.json) {
141
+ ctx.out(JSON.stringify(result, null, 2));
142
+ return;
143
+ }
144
+ for (const line of configureSummaryLines(result))
145
+ ctx.out(line);
146
+ }
@@ -0,0 +1,160 @@
1
+ import { getPaywallDataFromLabel, NamiCampaignManager, NamiEventEmitter, NamiFlowManager, NamiPaywallAction, NamiPaywallManager, PAYWALL_ACTION_EVENT, } from "@namiml/sdk-core";
2
+ import { compositeForPage, currentPageName } from "../render/composite.js";
3
+ import { actionForOnTap, noEventReason } from "../render/action-map.js";
4
+ /**
5
+ * Purchase outcomes. The CLI registers `TerminalPurchaseAdapter` and nothing
6
+ * else, so every one of these it ever sees was simulated — and a simulated
7
+ * PURCHASE_SUCCESS read as a real one is exactly the misreading to prevent.
8
+ */
9
+ const SIMULATED_ACTIONS = new Set([
10
+ NamiPaywallAction.PURCHASE_SUCCESS,
11
+ NamiPaywallAction.PURCHASE_FAILED,
12
+ NamiPaywallAction.PURCHASE_CANCELLED,
13
+ NamiPaywallAction.PURCHASE_PENDING,
14
+ NamiPaywallAction.PURCHASE_UNKNOWN,
15
+ NamiPaywallAction.PURCHASE_DEFERRED,
16
+ ]);
17
+ /** Keys a flow author may have named their event with; the first one present wins. */
18
+ const FLOW_NAME_KEYS = ["name", "event", "eventName", "type"];
19
+ /** A flow event has no schema, so its name is the author's own or a neutral stand-in. */
20
+ function flowEventName(event) {
21
+ for (const key of FLOW_NAME_KEYS) {
22
+ const value = event[key];
23
+ if (typeof value === "string" && value.trim() !== "")
24
+ return value;
25
+ }
26
+ return "flow event";
27
+ }
28
+ /** Subscribes the three sources into the bus and launches the placement. */
29
+ export async function startLaunch(label, bus, deps = {}) {
30
+ const flow = deps.flowManager ?? NamiFlowManager;
31
+ const paywalls = deps.paywallManager ?? NamiPaywallManager;
32
+ const emitter = deps.emitter ?? NamiEventEmitter.getInstance();
33
+ const unsubscribes = [
34
+ flow.registerEventHandler((event) => {
35
+ const payload = { ...event };
36
+ bus.record({ source: "flow", name: flowEventName(payload), payload });
37
+ }),
38
+ flow.registerStepHandoff((tag, data, complete) => {
39
+ bus.record({
40
+ source: "handoff",
41
+ name: tag,
42
+ payload: {
43
+ tag,
44
+ data,
45
+ outcome: "done",
46
+ handler: "NamiFlowManager.registerStepHandoff",
47
+ note: "the CLI completed this handoff so the flow could continue — your app decides the real outcome",
48
+ },
49
+ });
50
+ // A flow waits on `complete`; leaving it uncalled would wedge the launch.
51
+ // Handlers written against the pre-outcome signature get no `complete`.
52
+ if (typeof complete === "function")
53
+ complete();
54
+ }),
55
+ paywalls.registerDeeplinkActionHandler((url) => {
56
+ bus.record({
57
+ source: "paywall",
58
+ name: NamiPaywallAction.DEEPLINK,
59
+ payload: {
60
+ deeplinkUrl: url,
61
+ handler: "NamiPaywallManager.registerDeeplinkActionHandler",
62
+ note: "this arrived on the deeplink handler, not the paywall action event — namiDeeplink never reports as an action",
63
+ },
64
+ });
65
+ }),
66
+ ];
67
+ const dispose = () => {
68
+ for (const unsubscribe of unsubscribes) {
69
+ try {
70
+ unsubscribe();
71
+ }
72
+ catch {
73
+ // One broken unregister is not allowed to strand the others.
74
+ }
75
+ }
76
+ unsubscribes.length = 0;
77
+ };
78
+ const record = (event) => {
79
+ const payload = { ...event };
80
+ delete payload.action;
81
+ const name = String(event.action);
82
+ bus.record({ source: "paywall", name, payload, ...(SIMULATED_ACTIONS.has(name) ? { simulated: true } : {}) });
83
+ };
84
+ try {
85
+ if (deps.launch)
86
+ deps.launch(label, record);
87
+ else
88
+ NamiCampaignManager.launch(label, undefined, undefined, undefined, record);
89
+ }
90
+ catch (error) {
91
+ dispose();
92
+ throw error;
93
+ }
94
+ // `sdk/web`'s PaywallScreen reports SHOW_PAYWALL the moment the paywall
95
+ // renders (`postPaywallScreenData`). The CLI is the renderer here, so it owes
96
+ // the same event — and it goes out on the shared emitter, which is where
97
+ // `launch` just added the action callback.
98
+ emitter.emit(PAYWALL_ACTION_EVENT, { action: NamiPaywallAction.SHOW_PAYWALL, campaignLabel: label });
99
+ return { dispose };
100
+ }
101
+ /** Nothing to outline: a flow placement, or a paywall core could not resolve. */
102
+ const NO_PAGE = { pageName: "", sections: [], truncated: 0, unresolved: true };
103
+ /**
104
+ * The page outline for a placement, from core's own lookup.
105
+ *
106
+ * `getPaywallDataFromLabel` is the same lookup `NamiCampaignManager.launch`
107
+ * performs, so the pane outlines the page the launch actually served. A flow
108
+ * placement carries no paywall here — its screens report as flow events.
109
+ */
110
+ /**
111
+ * The outline for a placement, or an `unresolved` composite.
112
+ *
113
+ * A placement only resolves a paywall when that paywall was in the set the SDK
114
+ * fetched for the **configured device's form factor**. Verified against staging
115
+ * Test Nami: as a host desktop only 12 paywalls come back and this label
116
+ * resolves nothing, while as an iPhone 150 come back and it resolves — and even
117
+ * then 12 of 103 paywall placements stay unresolved because they are authored
118
+ * for other form factors. So an empty outline is usually a device mismatch, not
119
+ * an authoring mistake, and the screen has to say which.
120
+ */
121
+ export function compositeForPlacement(label) {
122
+ try {
123
+ const { paywall } = getPaywallDataFromLabel(label, "label");
124
+ if (!paywall)
125
+ return NO_PAGE;
126
+ return compositeForPage(paywall, currentPageName(paywall));
127
+ }
128
+ catch {
129
+ // A placement the SDK cannot resolve is an empty outline, never a crash:
130
+ // the events pane is still the point of the screen.
131
+ return NO_PAGE;
132
+ }
133
+ }
134
+ /**
135
+ * Press one node the way the web SDK does: map its `onTap`, then report the
136
+ * action on the shared emitter so it reaches the developer's own
137
+ * `actionCallback` on exactly the path a real app uses.
138
+ *
139
+ * A control the SDK maps to no action reports nothing and says why — inventing
140
+ * an event the SDK would never have sent is the one thing this screen must not do.
141
+ */
142
+ export function pressNode(node, deps = {}) {
143
+ const fn = node.onTap?.function;
144
+ if (fn === undefined)
145
+ return { reason: "This part of the page is not a control — there is nothing to press." };
146
+ const mapped = actionForOnTap(node.onTap);
147
+ if (!mapped)
148
+ return { reason: noEventReason(fn) };
149
+ if (mapped.requiresSku) {
150
+ return {
151
+ reason: `${fn} needs a SKU in play, and the CLI has no product selected — the SDK sends nothing without one.`,
152
+ };
153
+ }
154
+ const emitter = deps.emitter ?? NamiEventEmitter.getInstance();
155
+ emitter.emit(PAYWALL_ACTION_EVENT, {
156
+ action: mapped.action,
157
+ ...(mapped.componentChange ? { componentChange: mapped.componentChange } : {}),
158
+ });
159
+ return { action: mapped.action };
160
+ }
@@ -0,0 +1,52 @@
1
+ import { isNamiFlowCampaign, NamiCampaignManager, NamiPaywallManager } from "@namiml/sdk-core";
2
+ import { placementLabel } from "../sdk/summary.js";
3
+ /**
4
+ * One row per campaign rule, de-duplicated by label and sorted, mirroring
5
+ * `summarizePlacements()` — both take the label from `placementLabel()`.
6
+ *
7
+ * `kind` comes from the campaign's own `flow` field, which core's exported
8
+ * `isNamiFlowCampaign()` guard is defined on: a rule that carries a flow object
9
+ * serves a flow, everything else serves a paywall. Nothing is inferred.
10
+ *
11
+ * `paywallName` needs `paywalls` because a rule carries only the paywall's **id**
12
+ * (`NamiCampaign.paywall`), never its name; without the list it stays unset.
13
+ */
14
+ export function listPlacements(campaigns, paywalls) {
15
+ if (!Array.isArray(campaigns))
16
+ return [];
17
+ const names = new Map();
18
+ for (const p of paywalls ?? [])
19
+ if (p?.id && p.name)
20
+ names.set(p.id, p.name);
21
+ const byLabel = new Map();
22
+ for (const campaign of campaigns) {
23
+ const label = placementLabel(campaign);
24
+ // First rule wins: `allCampaigns()` returns API rules before initial-config ones.
25
+ if (!label || byLabel.has(label))
26
+ continue;
27
+ const paywallName = typeof campaign.paywall === "string" ? names.get(campaign.paywall) : undefined;
28
+ byLabel.set(label, {
29
+ label,
30
+ kind: isNamiFlowCampaign(campaign) ? "flow" : "paywall",
31
+ ...(campaign.name?.trim() ? { campaignName: campaign.name.trim() } : {}),
32
+ ...(campaign.segment === undefined ? {} : { segmentId: campaign.segment }),
33
+ ...(paywallName ? { paywallName } : {}),
34
+ });
35
+ }
36
+ return [...byLabel.values()].sort((a, b) => a.label.localeCompare(b.label));
37
+ }
38
+ /**
39
+ * What the configured core currently holds: its campaign rules and its paywalls.
40
+ *
41
+ * One pair, read in one place, so `nami placements` and the Shell's launch
42
+ * picker can never end up listing different things. Paywalls come along because
43
+ * a rule names its paywall by id only (see `listPlacements`).
44
+ */
45
+ export function corePlacementData() {
46
+ return { campaigns: NamiCampaignManager.allCampaigns(), paywalls: NamiPaywallManager.allPaywalls() };
47
+ }
48
+ /** `corePlacementData()` as rows. Throws if the SDK is not configured in this process. */
49
+ export function corePlacements() {
50
+ const { campaigns, paywalls } = corePlacementData();
51
+ return listPlacements(campaigns, paywalls);
52
+ }
package/dist/program.js CHANGED
@@ -79,6 +79,14 @@ export function createProgram(h, options = {}) {
79
79
  .action(async (_opts, cmd) => {
80
80
  process.exitCode = await h.configure(cmd.optsWithGlobals());
81
81
  });
82
+ program
83
+ .command("placements")
84
+ .description("List the placements the configured SDK can launch. Needs a current selection (run nami or nami configure first).")
85
+ .option(HOST_FLAG, HOST_HELP)
86
+ .option("--json", "machine-readable output")
87
+ .action(async (_opts, cmd) => {
88
+ process.exitCode = await h.placements(cmd.optsWithGlobals());
89
+ });
82
90
  program
83
91
  .command("use")
84
92
  .description("Switch the current organization, app, app platform or device (headless).")
@@ -110,9 +118,17 @@ export function createProgram(h, options = {}) {
110
118
  });
111
119
  program
112
120
  .command("onboarding")
113
- .description("Show the onboarding step, or start over with --reset (keeps your sign-in).")
121
+ .description("Show the onboarding step, start over with --reset, or onboard headlessly with the flags below.")
114
122
  .option(HOST_FLAG, HOST_HELP)
115
123
  .option("--reset", "forget the current selection, recent list and onboarding progress")
124
+ .option("--org <id-or-name>", "organization to onboard into (re-scopes the access token)")
125
+ .option("--app <id-or-name>", "app id, exact name, or unique substring")
126
+ .option("--platform <id-or-name>", "platform id, exact name, or unique substring")
127
+ .option("--app-platform-id <uuid>", "onboard straight onto an App Platform ID")
128
+ .option("--from-sdkconfig <path>", "read the App Platform ID and host from a test app's sdkconfig.json")
129
+ .option("--device <id-or-name>", "target device preset, previewer id or name")
130
+ .option("-y, --yes", "answer yes to anything the interactive run would ask (e.g. an SDK-disabled platform)")
131
+ .option("--json", "machine-readable result")
116
132
  .action(async (_opts, cmd) => {
117
133
  process.exitCode = await h.onboarding(cmd.optsWithGlobals());
118
134
  });