@namiml/cli 3.4.11-dev.202609230307 → 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);
@@ -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,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,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).")
@@ -0,0 +1,101 @@
1
+ import { NamiPaywallAction } from "@namiml/sdk-core";
2
+ /**
3
+ * The one mapping from a component's `onTap.function` to the paywall action the
4
+ * SDK reports — the CLI's copy of what the web SDK does.
5
+ *
6
+ * The strings below are redeclared from `sdk/web/src/components/utils/eventConst.ts`
7
+ * (`NAMI_CLOSE_PAYWALL = "namiClosePaywall"` and friends); the CLI must not
8
+ * import from `sdk/web`, so that file is the source and this is the copy.
9
+ *
10
+ * The mapping itself is `handleAction` in `sdk/web/src/components/utils/actionTap.ts`.
11
+ * It initialises `action = UNKNOWN`, runs one switch, and emits on
12
+ * `PAYWALL_ACTION_EVENT` **only when the switch moved `action` off `UNKNOWN`**.
13
+ * So the table here is not "every function web handles" — it is every function
14
+ * that leaves web with an action to emit:
15
+ *
16
+ * | onTap.function | NamiPaywallAction |
17
+ * |---------------------------|-------------------------|
18
+ * | namiClosePaywall | CLOSE_PAYWALL |
19
+ * | namiRestorePurchases | RESTORE_PURCHASES |
20
+ * | namiSignIn | SIGN_IN |
21
+ * | namiBuySKU | BUY_SKU (with a SKU) |
22
+ * | namiSelectSKU | SELECT_SKU (with a SKU) |
23
+ * | namiPurchaseSelectedSKU | PURCHASE_SELECTED_SKU (with a SKU) |
24
+ * | namiNavigateToScreen | PAGE_CHANGE |
25
+ * | setState | TOGGLE_CHANGE |
26
+ *
27
+ * Everything else deliberately emits nothing: `namiDeeplink` reports through
28
+ * `NamiPaywallManager.registerDeeplinkActionHandler` instead, the four video
29
+ * functions go to web's VideoService (which emits its own video events),
30
+ * `namiReloadProducts` reloads store products, `""` is web's `UNSUPPORTED`, and
31
+ * an unrecognised function falls through web's `default:` branch untouched.
32
+ */
33
+ /** `onTap.function` values, from `sdk/web/src/components/utils/eventConst.ts`. */
34
+ const NAMI_CLOSE_PAYWALL = "namiClosePaywall";
35
+ const NAMI_RESTORE_PURCHASES = "namiRestorePurchases";
36
+ const NAMI_SIGN_IN = "namiSignIn";
37
+ const NAMI_BUY_SKU = "namiBuySKU";
38
+ const SET_STATE = "setState";
39
+ const NAMI_SELECT_SKU = "namiSelectSKU";
40
+ const NAMI_PURCHASE_SELECTED_SKU = "namiPurchaseSelectedSKU";
41
+ const NAMI_DEEP_LINK = "namiDeeplink";
42
+ const NAMI_NAVIGATE_TO_SCREEN = "namiNavigateToScreen";
43
+ const NAMI_RELOAD_PRODUCTS = "namiReloadProducts";
44
+ const UNSUPPORTED = "";
45
+ const NAMI_PLAY_VIDEO = "namiPlayVideo";
46
+ const NAMI_PAUSE_VIDEO = "namiPauseVideo";
47
+ const NAMI_MUTE_VIDEO = "namiMuteVideo";
48
+ const NAMI_UNMUTE_VIDEO = "namiUnmuteVideo";
49
+ /** The actions web guards behind `if (sku)`. */
50
+ const SKU_ACTIONS = {
51
+ [NAMI_BUY_SKU]: NamiPaywallAction.BUY_SKU,
52
+ [NAMI_SELECT_SKU]: NamiPaywallAction.SELECT_SKU,
53
+ [NAMI_PURCHASE_SELECTED_SKU]: NamiPaywallAction.PURCHASE_SELECTED_SKU,
54
+ };
55
+ /** The actions web emits unconditionally. */
56
+ const PLAIN_ACTIONS = {
57
+ [NAMI_CLOSE_PAYWALL]: NamiPaywallAction.CLOSE_PAYWALL,
58
+ [NAMI_RESTORE_PURCHASES]: NamiPaywallAction.RESTORE_PURCHASES,
59
+ [NAMI_SIGN_IN]: NamiPaywallAction.SIGN_IN,
60
+ [SET_STATE]: NamiPaywallAction.TOGGLE_CHANGE,
61
+ };
62
+ /** null when this onTap emits no paywall action event — see the table in the header comment. */
63
+ export function actionForOnTap(onTap) {
64
+ const fn = onTap?.function;
65
+ if (fn === undefined)
66
+ return null;
67
+ const withSku = SKU_ACTIONS[fn];
68
+ if (withSku)
69
+ return { action: withSku, requiresSku: true };
70
+ if (fn === NAMI_NAVIGATE_TO_SCREEN) {
71
+ const screen = onTap?.parameters?.screen;
72
+ return {
73
+ action: NamiPaywallAction.PAGE_CHANGE,
74
+ // Web sets `componentChange = { name: onTap.parameters.screen }`.
75
+ ...(screen === undefined ? {} : { componentChange: { name: screen } }),
76
+ requiresSku: false,
77
+ };
78
+ }
79
+ const plain = PLAIN_ACTIONS[fn];
80
+ return plain ? { action: plain, requiresSku: false } : null;
81
+ }
82
+ /** Why a press produced no event, for the "pressed, no event" note in the UI. */
83
+ export function noEventReason(fn) {
84
+ switch (fn) {
85
+ case NAMI_DEEP_LINK:
86
+ return "namiDeeplink reports through NamiPaywallManager.registerDeeplinkActionHandler, not the paywall action event — watch for a deeplink row instead.";
87
+ case NAMI_RELOAD_PRODUCTS:
88
+ return "namiReloadProducts re-fetches store products; the SDK sends no paywall action for it.";
89
+ case NAMI_PLAY_VIDEO:
90
+ case NAMI_PAUSE_VIDEO:
91
+ case NAMI_MUTE_VIDEO:
92
+ case NAMI_UNMUTE_VIDEO:
93
+ return `${fn} is handled by the platform's video service, which reports its own video events; the CLI has no video to play.`;
94
+ case UNSUPPORTED:
95
+ return "This control carries no action — the paywall marked it unsupported, so nothing is sent.";
96
+ default:
97
+ return actionForOnTap({ function: fn })
98
+ ? undefined
99
+ : `${fn} is not one of the actions the SDK maps to a paywall event, so pressing it sends nothing.`;
100
+ }
101
+ }
@@ -0,0 +1,161 @@
1
+ /** Nodes one composite may carry; the rest are counted into `truncated`. */
2
+ export const COMPOSITE_MAX_NODES = 200;
3
+ /** Page keys in the order a page reads, paired with the section they become. */
4
+ const SECTIONS = [
5
+ { name: "header", key: "header" },
6
+ { name: "content", key: "contentContainer" },
7
+ { name: "footer", key: "footer" },
8
+ { name: "background", key: "backgroundContainer" },
9
+ ];
10
+ /** Child arrays, in the order the SDKs render them. */
11
+ const CHILD_LIST_KEYS = [
12
+ "components",
13
+ "productFeaturedComponents",
14
+ "productBaseComponents",
15
+ "pausedComponents",
16
+ "playingComponents",
17
+ "mutedComponents",
18
+ "volumeComponents",
19
+ ];
20
+ /** Single-child slots. */
21
+ const CHILD_NODE_KEYS = ["collapseHeader", "groupHeaderTemplate"];
22
+ /**
23
+ * Walk one page of a paywall template into a flat, depth-tagged outline.
24
+ *
25
+ * The walk follows the TV full-page announcement contract documented on
26
+ * `TPages.screenreaderText`: source order throughout, containers recurse,
27
+ * `text` / `text-list` carry their strings. It differs in two ways, because a
28
+ * pane has to show structure rather than speak it: media components render a
29
+ * placeholder naming the media instead of being skipped, and a `hidden: true`
30
+ * node (and its subtree) is emitted flagged rather than dropped, so the pane
31
+ * can dim it. A hidden node is never focusable.
32
+ *
33
+ * This is a structural outline, not a layout engine — no positions, sizes or
34
+ * colours are computed.
35
+ */
36
+ export function compositeForPage(paywall, pageName) {
37
+ const page = pagesOf(paywall).find((candidate) => candidate.name === pageName);
38
+ if (!page)
39
+ return { pageName, sections: [], truncated: 0 };
40
+ const walked = SECTIONS.map(({ name, key }) => {
41
+ const nodes = [];
42
+ for (const root of rootsOf(page[key]))
43
+ walk(root, 0, false, nodes);
44
+ return { name, nodes };
45
+ });
46
+ const sections = [];
47
+ let remaining = COMPOSITE_MAX_NODES;
48
+ let truncated = 0;
49
+ for (const section of walked) {
50
+ const kept = section.nodes.slice(0, Math.max(remaining, 0));
51
+ truncated += section.nodes.length - kept.length;
52
+ remaining -= kept.length;
53
+ if (kept.length > 0)
54
+ sections.push({ name: section.name, nodes: kept });
55
+ }
56
+ return { pageName: typeof page.name === "string" ? page.name : pageName, sections, truncated };
57
+ }
58
+ /** The page the live state says is showing, falling back to template.initialState.currentPage. */
59
+ export function currentPageName(paywall, state) {
60
+ const live = text(state?.currentPage);
61
+ if (live)
62
+ return live;
63
+ const initial = text(paywall?.template?.initialState?.currentPage);
64
+ if (initial)
65
+ return initial;
66
+ return text(pagesOf(paywall)[0]?.name) ?? "";
67
+ }
68
+ function walk(node, depth, inheritedHidden, out) {
69
+ const hidden = inheritedHidden || node.hidden === true;
70
+ const onTap = userAction(node.onTap);
71
+ const entry = { depth, kind: text(node.component) ?? "unknown" };
72
+ const content = labelFor(node, onTap !== undefined);
73
+ if (content)
74
+ entry.content = content;
75
+ if (onTap && !hidden)
76
+ entry.onTap = onTap;
77
+ const id = text(node.id);
78
+ if (id)
79
+ entry.id = id;
80
+ const testId = text(node.testId);
81
+ if (testId)
82
+ entry.testId = testId;
83
+ if (hidden)
84
+ entry.hidden = true;
85
+ out.push(entry);
86
+ for (const key of CHILD_LIST_KEYS) {
87
+ for (const child of rootsOf(node[key]))
88
+ walk(child, depth + 1, hidden, out);
89
+ }
90
+ for (const key of CHILD_NODE_KEYS) {
91
+ const child = node[key];
92
+ if (isNode(child))
93
+ walk(child, depth + 1, hidden, out);
94
+ }
95
+ }
96
+ /** A focusable node is labelled by its screenreaderText when it has one. */
97
+ function labelFor(node, focusable) {
98
+ if (focusable) {
99
+ const spoken = text(node.screenreaderText);
100
+ if (spoken)
101
+ return spoken;
102
+ }
103
+ return contentFor(node);
104
+ }
105
+ function contentFor(node) {
106
+ switch (text(node.component)) {
107
+ case "text-list":
108
+ return lines(node.texts);
109
+ case "image":
110
+ case "svgImage":
111
+ return `[image: ${mediaName(node)}]`;
112
+ case "videoUrl":
113
+ return `[video: ${mediaName(node)}]`;
114
+ case "symbol":
115
+ return `[icon: ${text(node.name) ?? "symbol"}]`;
116
+ case "productContainer":
117
+ case "flexProductContainer":
118
+ return `[products: ${text(node.subsetGroup) ?? text(node.products) ?? "all"}]`;
119
+ default:
120
+ return text(node.text);
121
+ }
122
+ }
123
+ /** The media's own name: its alt text, else the last segment of its url. */
124
+ function mediaName(node) {
125
+ const alt = text(node.alt);
126
+ if (alt)
127
+ return alt;
128
+ const url = text(node.url);
129
+ if (!url)
130
+ return "media";
131
+ const path = url.split(/[?#]/)[0];
132
+ const last = path.split("/").filter(Boolean).pop();
133
+ return last ?? url;
134
+ }
135
+ function lines(value) {
136
+ if (!Array.isArray(value))
137
+ return undefined;
138
+ const items = value.map(text).filter((item) => item !== undefined);
139
+ return items.length > 0 ? items.join(" · ") : undefined;
140
+ }
141
+ function userAction(value) {
142
+ if (!isNode(value))
143
+ return undefined;
144
+ return typeof value.function === "string" ? value : undefined;
145
+ }
146
+ function pagesOf(paywall) {
147
+ const pages = paywall?.template?.pages;
148
+ return Array.isArray(pages) ? pages.filter(isNode) : [];
149
+ }
150
+ /** A section's roots: an array of components, a single container, or nothing. */
151
+ function rootsOf(value) {
152
+ if (Array.isArray(value))
153
+ return value.filter(isNode);
154
+ return isNode(value) ? [value] : [];
155
+ }
156
+ function isNode(value) {
157
+ return typeof value === "object" && value !== null && !Array.isArray(value);
158
+ }
159
+ function text(value) {
160
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
161
+ }
@@ -0,0 +1,135 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { palette } from "../theme.js";
4
+ /**
5
+ * The known `NamiPaywallEvent` fields, in the order the pane shows them.
6
+ * Copied from `sdk/core/src/types/externals/paywall.ts` — the whole point of
7
+ * the expanded view is that a field the SDK did **not** send is still visible,
8
+ * so this list has to be the SDK's, not whatever happened to arrive.
9
+ */
10
+ export const PAYWALL_EVENT_KEYS = [
11
+ "sku",
12
+ "campaignId",
13
+ "campaignName",
14
+ "campaignType",
15
+ "campaignLabel",
16
+ "campaignUrl",
17
+ "paywallId",
18
+ "paywallName",
19
+ "componentChange",
20
+ "segmentId",
21
+ "externalSegmentId",
22
+ "deeplinkUrl",
23
+ "purchaseError",
24
+ "purchases",
25
+ "timeSpentOnPaywall",
26
+ "videoMetaData",
27
+ "purchaseChannel",
28
+ ];
29
+ /** A handoff carries these three; a flow event has no schema at all. */
30
+ export const HANDOFF_KEYS = ["tag", "data", "outcome"];
31
+ /** Stands in for a field the SDK did not send. */
32
+ const ABSENT = "—";
33
+ const SOURCE_COLOR = {
34
+ paywall: palette.secondaryBlue,
35
+ flow: palette.aqua,
36
+ handoff: palette.primaryBlue,
37
+ };
38
+ const pad2 = (n) => String(n).padStart(2, "0");
39
+ /** `hh:mm:ss` in the developer's own zone — they are watching this live. */
40
+ function formatTime(at) {
41
+ const d = new Date(at);
42
+ if (Number.isNaN(d.getTime()))
43
+ return "--:--:--";
44
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
45
+ }
46
+ /** `null` means "the SDK did not send this", which the caller renders as a dash. */
47
+ function formatValue(value) {
48
+ if (value === undefined || value === null)
49
+ return null;
50
+ if (typeof value === "string")
51
+ return value === "" ? '""' : value;
52
+ if (typeof value === "number" || typeof value === "boolean")
53
+ return String(value);
54
+ try {
55
+ return JSON.stringify(value);
56
+ }
57
+ catch {
58
+ return String(value);
59
+ }
60
+ }
61
+ function clip(text, max) {
62
+ if (max <= 1 || text.length <= max)
63
+ return text;
64
+ return `${text.slice(0, max - 1)}…`;
65
+ }
66
+ /** Read a nested string-ish field, e.g. an object's `name` or `id`. */
67
+ function nameOf(value) {
68
+ if (typeof value === "string")
69
+ return value;
70
+ if (value && typeof value === "object") {
71
+ const record = value;
72
+ for (const key of ["skuId", "name", "id", "displayName"]) {
73
+ const inner = record[key];
74
+ if (typeof inner === "string" && inner !== "")
75
+ return inner;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ /**
81
+ * The one field worth putting on a collapsed row. Never guesses at a schema for
82
+ * a flow event: it just shows that event's first author-defined key.
83
+ */
84
+ export function salientField(event) {
85
+ const { payload } = event;
86
+ if (event.source === "paywall") {
87
+ for (const key of ["sku", "purchaseError", "deeplinkUrl", "componentChange", "paywallName", "campaignName"]) {
88
+ const value = nameOf(payload[key]) ?? formatValue(payload[key]);
89
+ if (value)
90
+ return value;
91
+ }
92
+ return null;
93
+ }
94
+ if (event.source === "handoff") {
95
+ return formatValue(payload.tag);
96
+ }
97
+ const [firstKey] = Object.keys(payload);
98
+ if (firstKey === undefined)
99
+ return null;
100
+ const value = formatValue(payload[firstKey]);
101
+ return value === null ? firstKey : `${firstKey}=${value}`;
102
+ }
103
+ /** The known keys for a source, then anything extra the payload carried. */
104
+ function fieldsFor(event) {
105
+ const known = event.source === "paywall" ? PAYWALL_EVENT_KEYS : event.source === "handoff" ? HANDOFF_KEYS : [];
106
+ const extras = Object.keys(event.payload).filter((key) => !known.includes(key));
107
+ return [...known, ...extras].map((key) => [key, formatValue(event.payload[key])]);
108
+ }
109
+ function EventRow({ event, selected, width }) {
110
+ const head = `${formatTime(event.at)} · ${event.source} · ${event.name}`;
111
+ // "simulated" is part of the head: it must never be the thing that gets dropped.
112
+ const mark = event.simulated ? " · simulated" : "";
113
+ const salient = salientField(event);
114
+ const room = width - 2 - head.length - mark.length;
115
+ // A narrow row drops the salient column rather than clipping the name into ambiguity.
116
+ const tail = salient !== null && room >= salient.length + 3 ? ` · ${salient}` : "";
117
+ return (_jsxs(Text, { children: [_jsx(Text, { color: palette.aqua, children: selected ? "›" : " " }), " ", _jsx(Text, { dimColor: true, children: formatTime(event.at) }), " · ", _jsx(Text, { color: SOURCE_COLOR[event.source], children: event.source }), " · ", _jsx(Text, { bold: selected, children: event.name }), event.simulated && _jsx(Text, { color: palette.coral, children: " \u00B7 simulated" }), tail && _jsx(Text, { dimColor: true, children: tail })] }));
118
+ }
119
+ function ExpandedEvent({ event, width }) {
120
+ const fields = fieldsFor(event);
121
+ const labelWidth = fields.reduce((longest, [key]) => Math.max(longest, key.length), 0);
122
+ const valueWidth = Math.max(12, width - labelWidth - 4);
123
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: palette.aqua, children: event.name }), _jsxs(Text, { dimColor: true, children: [" \u00B7 seq ", event.seq, " \u00B7 ", formatTime(event.at), " \u00B7 ", event.source] })] }), event.simulated && (_jsx(Text, { color: palette.coral, children: "simulated \u00B7 the CLI's purchase adapter produced this, not a real store" })), fields.length === 0 ? (_jsx(Text, { dimColor: true, children: "this event carried no fields" })) : (fields.map(([key, value]) => (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: key.padEnd(labelWidth) }), " ", value === null ? _jsx(Text, { dimColor: true, children: ABSENT }) : _jsx(Text, { children: clip(value, valueWidth) })] }, key))))] }));
124
+ }
125
+ /**
126
+ * What the SDK just sent, as it sent it.
127
+ *
128
+ * Pure: it renders the props it is handed and nothing else. A field the SDK
129
+ * omitted still gets a row with a dash, because "did my paywall send this?" is
130
+ * the question this pane exists to answer, and an omitted row cannot answer it.
131
+ */
132
+ export function EventsPane({ events, selectedSeq, paused, expanded, width }) {
133
+ const selected = selectedSeq === null ? undefined : events.find((event) => event.seq === selectedSeq);
134
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Events" }), " ", _jsx(Text, { bold: true, children: events.length }), paused && _jsx(Text, { color: palette.coral, children: " \u00B7 paused" })] }), events.length === 0 ? (_jsx(Text, { dimColor: true, children: "No events yet \u2014 interact with the placement and what it sends shows up here." })) : (events.map((event) => (_jsx(EventRow, { event: event, selected: event.seq === selectedSeq, width: width }, event.seq)))), expanded && selected && _jsx(ExpandedEvent, { event: selected, width: width })] }));
135
+ }
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { palette } from "../theme.js";
4
+ import { EventsPane } from "./EventsPane.js";
5
+ /**
6
+ * A launched placement: the page outline on one side, what the SDK reported on
7
+ * the other.
8
+ *
9
+ * Pure presentation plus input — it renders the props it is handed and turns
10
+ * keys into callbacks. The subscriptions, the bus and the focus index live in
11
+ * `src/placements/launch.ts` and `Shell`, so this file can be rendered from a
12
+ * test with nothing configured.
13
+ */
14
+ /** At or above this many columns the two panes sit side by side; below it they stack. */
15
+ export const SIDE_BY_SIDE_MIN_WIDTH = 100;
16
+ /** Columns between the two panes when they sit side by side. */
17
+ const GUTTER = 2;
18
+ /**
19
+ * What the arrows move between: a node the paywall gave an action to, that is
20
+ * actually on screen. The walker already drops `onTap` from a hidden subtree;
21
+ * the `hidden` check here means a change there can never make an invisible
22
+ * control pressable.
23
+ */
24
+ export function focusableNodes(composite) {
25
+ return composite.sections.flatMap((section) => section.nodes).filter((node) => node.onTap && !node.hidden);
26
+ }
27
+ function clip(text, max) {
28
+ if (max <= 1 || text.length <= max)
29
+ return text;
30
+ return `${text.slice(0, max - 1)}…`;
31
+ }
32
+ /** One outline row: `kind content → action`, indented by depth. */
33
+ function nodeLine(node) {
34
+ const parts = [node.kind];
35
+ if (node.content)
36
+ parts.push(node.content);
37
+ if (node.onTap)
38
+ parts.push(`→ ${node.onTap.function}`);
39
+ // A word, never colour alone — the dim is a second signal, not the only one.
40
+ if (node.hidden)
41
+ parts.push("· hidden");
42
+ return `${" ".repeat(node.depth)}${parts.join(" ")}`;
43
+ }
44
+ function CompositePane({ composite, placement, focused, width, deviceName }) {
45
+ const empty = composite.sections.length === 0;
46
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Page" }), " ", _jsx(Text, { children: composite.pageName || "—" })] }), empty && placement.kind === "flow" && (_jsx(Text, { dimColor: true, children: "A flow has no one page to outline \u2014 its screens and steps report as flow events in the pane." })), empty && placement.kind !== "flow" && composite.unresolved && (_jsxs(Text, { dimColor: true, children: ["No paywall for this placement on ", deviceName ? `a ${deviceName}` : "the configured device", " \u2014 it is probably authored for another form factor. Switch device (d on the home screen, or --device) and launch again."] })), empty && placement.kind !== "flow" && !composite.unresolved && (_jsx(Text, { dimColor: true, children: "Nothing to outline on this page \u2014 the paywall resolved but served no components." })), composite.sections.map((section) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: section.name }), section.nodes.map((node, index) => {
47
+ const isFocused = node === focused;
48
+ return (_jsxs(Text, { dimColor: node.hidden, children: [_jsx(Text, { color: palette.aqua, children: isFocused ? "›" : " " }), " ", _jsx(Text, { bold: isFocused, children: clip(nodeLine(node), Math.max(width - 2, 1)) })] }, `${section.name}-${index}`));
49
+ })] }, section.name))), composite.truncated > 0 && _jsxs(Text, { dimColor: true, children: ["+", composite.truncated, " more"] })] }));
50
+ }
51
+ export function LaunchScreen({ placement, composite, events, width, paused, expanded, selectedSeq, focusIndex, onPress, onMove, onTogglePause, onClear, onToggleExpand, onBack, notice, deviceName }) {
52
+ const focusable = focusableNodes(composite);
53
+ const focused = focusable[focusIndex];
54
+ useInput((input, key) => {
55
+ if (key.upArrow || key.leftArrow)
56
+ return onMove(-1);
57
+ if (key.downArrow || key.rightArrow)
58
+ return onMove(1);
59
+ if (key.return)
60
+ return void (focused && onPress(focused));
61
+ if (input === "p")
62
+ return onTogglePause();
63
+ if (input === "c")
64
+ return onClear();
65
+ if (input === "x")
66
+ return onToggleExpand();
67
+ if (input === "q" || key.escape)
68
+ return onBack();
69
+ });
70
+ const sideBySide = width >= SIDE_BY_SIDE_MIN_WIDTH;
71
+ const paneWidth = sideBySide ? Math.floor(width / 2) - 1 : width;
72
+ const eventsWidth = sideBySide ? width - paneWidth - GUTTER : width;
73
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: placement.label }), " ", _jsxs(Text, { color: palette.secondaryBlue, children: ["[", placement.kind, "]"] }), placement.campaignName && _jsxs(Text, { dimColor: true, children: [" \u00B7 ", placement.campaignName] })] }), _jsxs(Box, { flexDirection: sideBySide ? "row" : "column", marginTop: 1, children: [_jsx(CompositePane, { composite: composite, placement: placement, focused: focused, width: paneWidth, deviceName: deviceName }), _jsx(Box, { flexDirection: "column", marginLeft: sideBySide ? GUTTER : 0, marginTop: sideBySide ? 0 : 1, children: _jsx(EventsPane, { events: events, selectedSeq: selectedSeq ?? null, paused: paused, expanded: expanded, width: eventsWidth }) })] }), notice && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: palette.coral, children: notice }) })), _jsx(Text, { dimColor: true, children: "\u2191\u2193 move \u00B7 enter press \u00B7 p pause \u00B7 c clear \u00B7 x expand \u00B7 q back" })] }));
74
+ }
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { SelectList } from "../components/SelectList.js";
4
+ /** Pure picker over what `listPlacements()` found — it never fetches. */
5
+ export function PickPlacement({ placements, onSelect, onCancel }) {
6
+ if (placements.length === 0) {
7
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "No placements yet." }), _jsx(Text, { children: "Add a campaign in the Control Center, then run nami again." })] }));
8
+ }
9
+ return (_jsx(SelectList, { title: "Placements", items: placements.map((p) => ({ key: p.label, label: p.label, badge: p.kind, hint: p.campaignName })), onSelect: (key) => { const placement = placements.find((p) => p.label === key); if (placement)
10
+ onSelect(placement); }, onCancel: onCancel }));
11
+ }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from "react";
3
- import { Box, Text, useApp, useInput } from "ink";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
4
4
  import { LoginFlow } from "./LoginFlow.js";
5
5
  import { PickApp } from "./PickApp.js";
6
6
  import { PickPlatform } from "./PickPlatform.js";
@@ -10,6 +10,12 @@ import { SwitchScreen } from "./SwitchScreen.js";
10
10
  import { WelcomeScreen } from "./WelcomeScreen.js";
11
11
  import { NewToNami } from "./NewToNami.js";
12
12
  import { SuccessScreen } from "./SuccessScreen.js";
13
+ import { PickPlacement } from "./PickPlacement.js";
14
+ import { LaunchScreen, focusableNodes } from "./LaunchScreen.js";
15
+ import { corePlacements } from "../placements/list.js";
16
+ import { compositeForPlacement, pressNode, startLaunch as startLaunchFn } from "../placements/launch.js";
17
+ import { EventBus } from "../events/bus.js";
18
+ import { terminalColumns } from "../utils/terminal.js";
13
19
  import { deviceById, presetsFor, resolveDevice, toSelection } from "../devices/presets.js";
14
20
  import { SelectionHeader } from "./SelectionHeader.js";
15
21
  import { Thinking } from "../components/Thinking.js";
@@ -31,8 +37,9 @@ import TextInput from "ink-text-input";
31
37
  * pasted App Platform ID. Every path funnels back through `configure`, so the
32
38
  * SDK is re-configured in place and each platform keeps its own storage file.
33
39
  */
34
- export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitWhenDone = true, phase, hostWasExplicit = false, verify, configure }) {
40
+ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitWhenDone = true, phase, hostWasExplicit = false, verify, configure, placementsSource, compositeSource, startLaunch }) {
35
41
  const { exit } = useApp();
42
+ const { stdout } = useStdout();
36
43
  const [state, setState] = useState({ kind: "boot" });
37
44
  const [orgName, setOrgName] = useState(null);
38
45
  const [orgId, setOrgId] = useState(null);
@@ -41,6 +48,16 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
41
48
  const [pasted, setPasted] = useState("");
42
49
  // The paste route may retry on another host after a 404, without re-typing the id.
43
50
  const [pasteHost, setPasteHost] = useState(ctx.host);
51
+ // One bus for the whole session. A launch clears it, so a run always starts
52
+ // empty, but `seq` keeps climbing — it identifies events, it is not a count.
53
+ const [bus] = useState(() => new EventBus());
54
+ const [events, setEvents] = useState([]);
55
+ const [paused, setPaused] = useState(false);
56
+ const [expanded, setExpanded] = useState(false);
57
+ const [focusIndex, setFocusIndex] = useState(0);
58
+ const [notice, setNotice] = useState(undefined);
59
+ // The bus subscriber runs outside React's render, so pause is read from a ref.
60
+ const pausedRef = useRef(false);
44
61
  const finish = (code) => {
45
62
  onExit?.(code);
46
63
  if (exitWhenDone)
@@ -146,6 +163,68 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
146
163
  const named = (await findAppPlatformById(ctx.session, id)) ?? pastedStandIn(id);
147
164
  chooseDevice(named.app, named.platform);
148
165
  };
166
+ /** `l`: the placements this configured platform can launch. */
167
+ const openPlacements = (home) => {
168
+ try {
169
+ setState({ kind: "pick-placement", home, placements: (placementsSource ?? corePlacements)() });
170
+ }
171
+ catch (error) {
172
+ handleError(error);
173
+ }
174
+ };
175
+ /** Enter on a placement: reset the pane, outline the page, then subscribe and launch. */
176
+ const openLaunch = (home, placement) => {
177
+ bus.clear();
178
+ setEvents([]);
179
+ setFocusIndex(0);
180
+ setExpanded(false);
181
+ setNotice(undefined);
182
+ pausedRef.current = false;
183
+ setPaused(false);
184
+ setState({ kind: "launch", home, placement, composite: (compositeSource ?? compositeForPlacement)(placement.label) });
185
+ };
186
+ // Subscriptions live and die with the launch state, so leaving the screen
187
+ // unregisters every handler it added.
188
+ const launchLabel = state.kind === "launch" ? state.placement.label : null;
189
+ useEffect(() => {
190
+ if (launchLabel === null)
191
+ return;
192
+ let live = true;
193
+ let session;
194
+ const unsubscribe = bus.subscribe(() => {
195
+ if (!pausedRef.current)
196
+ setEvents(bus.all());
197
+ });
198
+ void (startLaunch ?? startLaunchFn)(launchLabel, bus)
199
+ .then((started) => {
200
+ if (live)
201
+ session = started;
202
+ else
203
+ started.dispose();
204
+ })
205
+ .catch((error) => setNotice(error instanceof Error ? error.message : String(error)));
206
+ return () => {
207
+ live = false;
208
+ unsubscribe();
209
+ session?.dispose();
210
+ };
211
+ }, [launchLabel]);
212
+ /** Press: report the mapped action, or say why this control reports nothing. */
213
+ const press = (node) => {
214
+ setNotice(pressNode(node).reason);
215
+ };
216
+ const togglePause = () => {
217
+ const next = !pausedRef.current;
218
+ pausedRef.current = next;
219
+ setPaused(next);
220
+ // Resuming catches up on everything that arrived while paused.
221
+ if (!next)
222
+ setEvents(bus.all());
223
+ };
224
+ const clearEvents = () => {
225
+ bus.clear();
226
+ setEvents([]);
227
+ };
149
228
  const onWelcomeChoice = (choice) => {
150
229
  if (choice === "signin") {
151
230
  step("signin");
@@ -259,7 +338,7 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
259
338
  if (input === "d" && presetsFor(state.platform.type).length > 1)
260
339
  return setState({ kind: "pick-device", app: state.app, platform: state.platform, current: state.device ?? undefined });
261
340
  if (input === "l")
262
- return setState({ ...state, launchHint: true });
341
+ return openPlacements(state);
263
342
  }, { isActive: state.kind === "home" });
264
343
  // The hub and its sub-screens keep the home state they came from, so the header
265
344
  // keeps showing what is loaded while you choose what to switch to.
@@ -271,5 +350,8 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
271
350
  .then(() => ctx.config.markOnboardingDone())
272
351
  .catch(() => { });
273
352
  setState({ kind: "home", app: state.app, platform: state.platform, device: state.device, result });
274
- }, onBack: () => chooseDevice(state.app, state.platform, state.device ?? undefined) })), state.kind === "home" && (_jsx(SuccessScreen, { app: state.app, platform: state.platform, device: state.device, result: state.result, host: ctx.host, canSwitchDevice: presetsFor(state.platform.type).length > 1, launchHint: state.launchHint })), state.kind === "welcome" && (_jsx(WelcomeScreen, { host: ctx.host, onChoose: onWelcomeChoice, onCancel: () => finish(0) })), state.kind === "new-to-nami" && (_jsx(NewToNami, { host: ctx.host, onContinue: () => { step("configure"); setState({ kind: "paste-id", home: null }); }, onBack: () => setState({ kind: "welcome" }) })), state.kind === "switch" && (_jsx(SwitchScreen, { recent: state.recent, current: state.current, orgName: orgName, canSwitchDevice: presetsFor(state.home.platform.type).length > 1, canSwitchOrg: true, onChoose: (action) => onSwitchChoice(state.home, action), onCancel: () => setState(state.home) })), state.kind === "pick-org" && (_jsx(PickOrg, { orgs: state.orgs, currentOrgId: orgId, onSelect: (org) => void switchOrg(org), onCancel: () => setState(state.home) })), state.kind === "paste-id" && (_jsxs(Box, { flexDirection: "column", children: [state.notice && _jsx(Text, { color: palette.coral, children: state.notice }), _jsxs(Box, { children: [_jsx(Text, { children: "App Platform ID: " }), _jsx(TextInput, { value: pasted, onChange: setPasted, onSubmit: (v) => { setPasted(""); void usePastedId(state.home, v); } })] }), _jsxs(Text, { dimColor: true, children: ["Checking ", pasteHost.name, ". nami platforms --app <app> lists them. Esc to go back."] })] })), state.kind === "fatal" && _jsx(Text, { color: palette.coral, children: state.message })] })] }));
353
+ }, onBack: () => chooseDevice(state.app, state.platform, state.device ?? undefined) })), state.kind === "home" && (_jsx(SuccessScreen, { app: state.app, platform: state.platform, device: state.device, result: state.result, host: ctx.host, canSwitchDevice: presetsFor(state.platform.type).length > 1 })), state.kind === "pick-placement" && (_jsx(PickPlacement, { placements: state.placements, onSelect: (placement) => openLaunch(state.home, placement), onCancel: () => setState(state.home) })), state.kind === "launch" && (_jsx(LaunchScreen, { placement: state.placement, composite: state.composite, deviceName: state.home.device?.displayName, events: events, width: Math.max(terminalColumns(stdout) - 2, 40), paused: paused, expanded: expanded, selectedSeq: events.at(-1)?.seq, focusIndex: focusIndex, onPress: press, onMove: (delta) => {
354
+ const count = focusableNodes(state.composite).length;
355
+ setFocusIndex((index) => (count === 0 ? 0 : (index + delta + count) % count));
356
+ }, onTogglePause: togglePause, onClear: clearEvents, onToggleExpand: () => setExpanded((on) => !on), onBack: () => setState(state.home), notice: notice })), state.kind === "welcome" && (_jsx(WelcomeScreen, { host: ctx.host, onChoose: onWelcomeChoice, onCancel: () => finish(0) })), state.kind === "new-to-nami" && (_jsx(NewToNami, { host: ctx.host, onContinue: () => { step("configure"); setState({ kind: "paste-id", home: null }); }, onBack: () => setState({ kind: "welcome" }) })), state.kind === "switch" && (_jsx(SwitchScreen, { recent: state.recent, current: state.current, orgName: orgName, canSwitchDevice: presetsFor(state.home.platform.type).length > 1, canSwitchOrg: true, onChoose: (action) => onSwitchChoice(state.home, action), onCancel: () => setState(state.home) })), state.kind === "pick-org" && (_jsx(PickOrg, { orgs: state.orgs, currentOrgId: orgId, onSelect: (org) => void switchOrg(org), onCancel: () => setState(state.home) })), state.kind === "paste-id" && (_jsxs(Box, { flexDirection: "column", children: [state.notice && _jsx(Text, { color: palette.coral, children: state.notice }), _jsxs(Box, { children: [_jsx(Text, { children: "App Platform ID: " }), _jsx(TextInput, { value: pasted, onChange: setPasted, onSubmit: (v) => { setPasted(""); void usePastedId(state.home, v); } })] }), _jsxs(Text, { dimColor: true, children: ["Checking ", pasteHost.name, ". nami platforms --app <app> lists them. Esc to go back."] })] })), state.kind === "fatal" && _jsx(Text, { color: palette.coral, children: state.message })] })] }));
275
357
  }
@@ -3,9 +3,9 @@ import { Box, Text } from "ink";
3
3
  import { palette } from "../theme.js";
4
4
  export const PLACEMENT_LIST_MAX = 8;
5
5
  /** The payoff after onboarding: what the SDK found, and what to do next. */
6
- export function SuccessScreen({ app, platform, device, result, host, canSwitchDevice, launchHint }) {
6
+ export function SuccessScreen({ app, platform, device, result, host, canSwitchDevice }) {
7
7
  const { counts, placements } = result;
8
8
  const shown = placements.slice(0, PLACEMENT_LIST_MAX);
9
9
  const more = placements.length - shown.length;
10
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: palette.aqua, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: palette.aqua, children: "Connected" }), " \u00B7 ", app.name, " \u00B7 ", platform.name, " ", _jsxs(Text, { color: palette.secondaryBlue, children: ["[", platform.typeLabel, "]"] }), device && _jsxs(Text, { children: [" \u00B7 ", device.displayName, " ", _jsxs(Text, { dimColor: true, children: ["(", device.width, "\u00D7", device.height, ")"] })] })] }), _jsxs(Text, { dimColor: true, children: ["App Platform ID ", result.appPlatformId, " \u00B7 ", host.name] }), _jsxs(Text, { children: ["SDK ", result.configureState, " \u00B7 pinned to @namiml/sdk-core ", result.sdkVersion, " \u00B7 ", result.sdkEnabled ? "SDK enabled" : _jsx(Text, { color: palette.coral, children: "SDK disabled" })] }), result.deviceId && _jsxs(Text, { dimColor: true, children: ["device ", result.deviceId] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["placements ", _jsx(Text, { bold: true, children: counts.placements }), " \u00B7 products ", _jsx(Text, { bold: true, children: counts.products }), " \u00B7 paywalls ", _jsx(Text, { bold: true, children: counts.paywalls })] }) }), shown.length > 0 && (_jsxs(Text, { dimColor: true, children: [shown.join(" · "), more > 0 ? ` · +${more} more` : ""] })), launchHint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: palette.aqua, children: ["Launching a placement lands in the next release.", placements.length > 0 ? ` The SDK found: ${shown.join(", ")}${more > 0 ? ` and ${more} more` : ""}.` : " This platform has no placements yet — add a campaign in the Control Center."] }) }))] }), _jsxs(Text, { dimColor: true, children: ["l launch a placement \u00B7 s switch app \u00B7 p switch platform", canSwitchDevice ? " · d switch device" : "", " \u00B7 q quit"] })] }));
10
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: palette.aqua, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: palette.aqua, children: "Connected" }), " \u00B7 ", app.name, " \u00B7 ", platform.name, " ", _jsxs(Text, { color: palette.secondaryBlue, children: ["[", platform.typeLabel, "]"] }), device && _jsxs(Text, { children: [" \u00B7 ", device.displayName, " ", _jsxs(Text, { dimColor: true, children: ["(", device.width, "\u00D7", device.height, ")"] })] })] }), _jsxs(Text, { dimColor: true, children: ["App Platform ID ", result.appPlatformId, " \u00B7 ", host.name] }), _jsxs(Text, { children: ["SDK ", result.configureState, " \u00B7 pinned to @namiml/sdk-core ", result.sdkVersion, " \u00B7 ", result.sdkEnabled ? "SDK enabled" : _jsx(Text, { color: palette.coral, children: "SDK disabled" })] }), result.deviceId && _jsxs(Text, { dimColor: true, children: ["device ", result.deviceId] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["placements ", _jsx(Text, { bold: true, children: counts.placements }), " \u00B7 products ", _jsx(Text, { bold: true, children: counts.products }), " \u00B7 paywalls ", _jsx(Text, { bold: true, children: counts.paywalls })] }) }), shown.length > 0 && (_jsxs(Text, { dimColor: true, children: [shown.join(" · "), more > 0 ? ` · +${more} more` : ""] }))] }), _jsxs(Text, { dimColor: true, children: ["l launch a placement \u00B7 s switch app \u00B7 p switch platform", canSwitchDevice ? " · d switch device" : "", " \u00B7 q quit"] })] }));
11
11
  }
@@ -1,10 +1,22 @@
1
- /** One human label per campaign rule: the placement label for label rules, else the rule name. */
1
+ /**
2
+ * The one human label a campaign rule goes by: the placement label for label
3
+ * rules, else the rule name. Empty when the rule carries neither.
4
+ *
5
+ * This is the single copy of that rule — `summarizePlacements()` (the configure
6
+ * summary and the Success screen) and `listPlacements()` (`src/placements/list.ts`,
7
+ * the picker and `nami placements`) both go through it so they cannot drift.
8
+ */
9
+ export function placementLabel(campaign) {
10
+ const label = campaign.type === "label" && typeof campaign.value === "string" && campaign.value.trim() ? campaign.value.trim() : campaign.name?.trim();
11
+ return label || undefined;
12
+ }
13
+ /** One human label per campaign rule, de-duplicated and sorted. */
2
14
  export function summarizePlacements(campaigns) {
3
15
  if (!Array.isArray(campaigns))
4
16
  return [];
5
17
  const labels = new Set();
6
18
  for (const c of campaigns) {
7
- const label = c.type === "label" && typeof c.value === "string" && c.value.trim() ? c.value.trim() : c.name?.trim();
19
+ const label = placementLabel(c);
8
20
  if (label)
9
21
  labels.add(label);
10
22
  }
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** CLI package version. Stamped by scripts/version.sh from the repo VERSION file — do not hand-edit. */
2
- export const NAMI_CLI_VERSION = "3.4.11-dev.202609230307";
2
+ export const NAMI_CLI_VERSION = "3.4.11-dev.202609231629";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namiml/cli",
3
- "version": "3.4.11-dev.202609230307",
3
+ "version": "3.4.11-dev.202609231629",
4
4
  "description": "Nami SDK developer CLI — terminal test app, implementation and flow checks",
5
5
  "keywords": [
6
6
  "nami",
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@namiml/sdk-core": "3.4.11-dev.202609230307",
38
+ "@namiml/sdk-core": "3.4.11-dev.202609231629",
39
39
  "commander": "^15",
40
40
  "env-paths": "^4",
41
41
  "ink": "^6.0.0",