@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.
@@ -1,13 +1,21 @@
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";
7
7
  import { PickDevice } from "./PickDevice.js";
8
8
  import { PickOrg } from "./PickOrg.js";
9
9
  import { SwitchScreen } from "./SwitchScreen.js";
10
+ import { WelcomeScreen } from "./WelcomeScreen.js";
11
+ import { NewToNami } from "./NewToNami.js";
10
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";
11
19
  import { deviceById, presetsFor, resolveDevice, toSelection } from "../devices/presets.js";
12
20
  import { SelectionHeader } from "./SelectionHeader.js";
13
21
  import { Thinking } from "../components/Thinking.js";
@@ -19,6 +27,7 @@ import { findApp, findPlatform, ResolveError } from "../control-center/resolve.j
19
27
  import { findAppPlatformById, pastedStandIn } from "../control-center/lookup.js";
20
28
  import { ConfigureScreen } from "./ConfigureScreen.js";
21
29
  import { appFrom, platformFrom, selectionFrom } from "../config/selection.js";
30
+ import { resolveHost } from "../hosts.js";
22
31
  import { verifyAppPlatform as verifyAppPlatformFn, VerifyError } from "../sdk/verify.js";
23
32
  import TextInput from "ink-text-input";
24
33
  /**
@@ -28,14 +37,27 @@ import TextInput from "ink-text-input";
28
37
  * pasted App Platform ID. Every path funnels back through `configure`, so the
29
38
  * SDK is re-configured in place and each platform keeps its own storage file.
30
39
  */
31
- export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitWhenDone = true, phase, verify, configure }) {
40
+ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitWhenDone = true, phase, hostWasExplicit = false, verify, configure, placementsSource, compositeSource, startLaunch }) {
32
41
  const { exit } = useApp();
42
+ const { stdout } = useStdout();
33
43
  const [state, setState] = useState({ kind: "boot" });
34
44
  const [orgName, setOrgName] = useState(null);
35
45
  const [orgId, setOrgId] = useState(null);
36
46
  const step = (name) => void ctx.config.setOnboardingStep(name).catch(() => { });
37
47
  const [queries, setQueries] = useState({ app: appQuery, platform: platformQuery, device: deviceQuery });
38
48
  const [pasted, setPasted] = useState("");
49
+ // The paste route may retry on another host after a 404, without re-typing the id.
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);
39
61
  const finish = (code) => {
40
62
  onExit?.(code);
41
63
  if (exitWhenDone)
@@ -120,22 +142,99 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
120
142
  * its name by scanning the apps this account can see. An id from another org
121
143
  * still works — it just shows as a pasted id until the device step.
122
144
  */
123
- const usePastedId = async (home, raw) => {
145
+ const usePastedId = async (home, raw, host = pasteHost) => {
124
146
  const id = raw.trim();
125
147
  if (!id)
126
148
  return;
149
+ setPasteHost(host);
127
150
  setState({ kind: "busy", label: "Checking that ID…" });
128
151
  try {
129
- await (verify ?? verifyAppPlatformFn)(ctx.host.baseUrl, id);
152
+ await (verify ?? verifyAppPlatformFn)(host.baseUrl, id);
130
153
  }
131
154
  catch (error) {
132
- if (error instanceof VerifyError)
133
- return setState({ kind: "paste-id", home, notice: error.message });
155
+ if (error instanceof VerifyError) {
156
+ // A first-run id pasted against production is most often a staging id.
157
+ const offer = error.code === "not_found" && host.name === "production" && !hostWasExplicit ? id : undefined;
158
+ const notice = offer ? `${error.message}\nPress s to try staging instead.` : error.message;
159
+ return setState({ kind: "paste-id", home, notice, offerStaging: offer });
160
+ }
134
161
  return handleError(error);
135
162
  }
136
163
  const named = (await findAppPlatformById(ctx.session, id)) ?? pastedStandIn(id);
137
164
  chooseDevice(named.app, named.platform);
138
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
+ };
228
+ const onWelcomeChoice = (choice) => {
229
+ if (choice === "signin") {
230
+ step("signin");
231
+ return setState({ kind: "signin" });
232
+ }
233
+ if (choice === "new")
234
+ return setState({ kind: "new-to-nami" });
235
+ step("configure");
236
+ setState({ kind: "paste-id", home: null });
237
+ };
139
238
  const onSwitchChoice = (home, action) => {
140
239
  if (action.kind === "recent")
141
240
  return useRecent(home, action.selection);
@@ -187,11 +286,18 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
187
286
  setOrgName(stored?.orgName ?? null);
188
287
  setOrgId(stored?.orgId ?? null);
189
288
  const signedIn = Boolean(stored || ctx.env.NAMI_TOKEN);
289
+ const { config } = await ctx.config.load();
290
+ const asked = Boolean(queries.app || queries.platform || queries.device);
291
+ // First run only: the step is still `welcome` and nothing has been chosen.
292
+ // Any later step resumes where it left off, so quitting mid-onboarding
293
+ // never re-greets you.
294
+ if (config.onboarding.step === "welcome" && !config.current && !asked) {
295
+ return setState({ kind: "welcome" });
296
+ }
190
297
  if (!signedIn) {
191
298
  step("signin");
192
299
  return setState({ kind: "signin" });
193
300
  }
194
- const { config } = await ctx.config.load();
195
301
  // Zero-prompt re-run: a persisted selection (and no --app/--platform override) goes straight to configure.
196
302
  if (config.current && !queries.app && !queries.platform) {
197
303
  const platform = platformFrom(config.current);
@@ -208,12 +314,16 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
208
314
  if (state.kind === "fatal")
209
315
  finish(state.code);
210
316
  }, [state.kind]);
211
- useInput((_input, key) => {
317
+ useInput((input, key) => {
212
318
  if (state.kind !== "paste-id")
213
319
  return;
320
+ if (state.offerStaging && input === "s") {
321
+ const id = state.offerStaging;
322
+ return void usePastedId(state.home, id, resolveHost("staging"));
323
+ }
214
324
  if (key.escape) {
215
325
  setPasted("");
216
- setState(state.home);
326
+ setState(state.home ?? { kind: "welcome" });
217
327
  }
218
328
  }, { isActive: state.kind === "paste-id" });
219
329
  useInput((input, key) => {
@@ -228,11 +338,11 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
228
338
  if (input === "d" && presetsFor(state.platform.type).length > 1)
229
339
  return setState({ kind: "pick-device", app: state.app, platform: state.platform, current: state.device ?? undefined });
230
340
  if (input === "l")
231
- return setState({ ...state, launchHint: true });
341
+ return openPlacements(state);
232
342
  }, { isActive: state.kind === "home" });
233
343
  // The hub and its sub-screens keep the home state they came from, so the header
234
344
  // keeps showing what is loaded while you choose what to switch to.
235
- const shown = "home" in state ? state.home : "app" in state ? state : {};
345
+ const shown = ("home" in state ? state.home : "app" in state ? state : null) ?? {};
236
346
  const header = (_jsx(SelectionHeader, { orgName: orgName, appName: shown.app?.name, platformName: shown.platform?.name, platformType: shown.platform?.typeLabel, deviceName: shown.device?.displayName, host: ctx.host.name }));
237
347
  return (_jsxs(Box, { flexDirection: "column", children: [header, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [state.kind === "boot" && _jsx(Thinking, { label: "Starting\u2026", phase: phase }), state.kind === "signin" && (_jsxs(Box, { flexDirection: "column", children: [state.notice && _jsx(Text, { color: palette.aqua, children: state.notice }), _jsx(LoginFlow, { auth: ctx.auth, store: ctx.store, host: ctx.host, exitWhenDone: false, phase: phase, onDone: (session) => { setOrgName(session.orgName); setOrgId(session.orgId); void loadApps(); }, onFail: (e) => fatal(e.message) })] })), state.kind === "busy" && _jsx(Thinking, { label: state.label, phase: phase }), state.kind === "pick-app" && (_jsx(PickApp, { apps: state.apps, orgName: orgName, baseUrl: ctx.host.baseUrl, currentAppId: state.current?.id, onSelect: (app) => void loadPlatforms(app), onCancel: () => finish(0) })), state.kind === "pick-platform" && (_jsx(PickPlatform, { appName: state.app.name, platforms: state.platforms, currentPlatformId: state.current?.id, onSelect: (platform) => chooseDevice(state.app, platform), onCancel: () => void loadApps(state.app) })), state.kind === "pick-device" && (_jsx(PickDevice, { platformType: state.platform.type, platformName: state.platform.name, currentDeviceId: state.current?.id, onSelect: (device) => { step("configure"); setState({ kind: "configure", app: state.app, platform: state.platform, device }); }, onCancel: () => void loadPlatforms(state.app, state.platform) })), state.kind === "configure" && (_jsx(ConfigureScreen, { host: ctx.host, appPlatformId: state.platform.id, device: state.device ? { platformType: state.platform.type, id: state.device.id } : undefined, phase: phase, verify: verify, configure: configure, onDone: (result) => {
238
348
  void ctx.config
@@ -240,5 +350,8 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
240
350
  .then(() => ctx.config.markOnboardingDone())
241
351
  .catch(() => { });
242
352
  setState({ kind: "home", app: state.app, platform: state.platform, device: state.device, result });
243
- }, 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 === "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); } })] }), _jsx(Text, { dimColor: true, children: "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 })] })] }));
244
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
  }
@@ -0,0 +1,15 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { SelectList } from "../components/SelectList.js";
4
+ const CHOICES = [
5
+ { key: "signin", label: "Sign in to pick my app", hint: "your Control Center account" },
6
+ { key: "paste", label: "I have an App Platform ID", hint: "no sign-in needed" },
7
+ { key: "new", label: "I'm new to Nami", hint: "start here" },
8
+ ];
9
+ /**
10
+ * First-run welcome: sign in, paste an App Platform ID, or read the explainer.
11
+ * Pure — it never fetches and never exits; `Shell` owns every transition.
12
+ */
13
+ export function WelcomeScreen({ host, onChoose, onCancel }) {
14
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [host.name, " \u00B7 ", host.baseUrl] }), _jsx(SelectList, { title: "Welcome to Nami", items: CHOICES, initialKey: "signin", onSelect: (key) => onChoose(key), onCancel: onCancel })] }));
15
+ }
@@ -1,12 +1,34 @@
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
  }
11
23
  return [...labels].sort((a, b) => a.localeCompare(b));
12
24
  }
25
+ /**
26
+ * The two lines every non-JSON configure path prints. `nami configure`, `nami
27
+ * onboarding` and any future caller share this so their output cannot drift.
28
+ */
29
+ export function configureSummaryLines(result) {
30
+ return [
31
+ `Configured ${result.appPlatformId} on ${result.host}: ${result.configureState}${result.sdkEnabled ? "" : " (SDK disabled)"}${result.device ? ` · ${result.device.displayName}` : ""}`,
32
+ `placements ${result.counts.placements} · products ${result.counts.products} · paywalls ${result.counts.paywalls}${result.deviceId ? ` · device ${result.deviceId}` : ""}`,
33
+ ];
34
+ }
@@ -0,0 +1,33 @@
1
+ import { spawn } from "node:child_process";
2
+ const nodeSpawn = (cmd, args, opts) => spawn(cmd, args, opts);
3
+ /** The platform's "open this in the default handler" command, or null when we have none. */
4
+ function opener(platform, url) {
5
+ switch (platform) {
6
+ case "darwin":
7
+ return { cmd: "open", args: [url] };
8
+ case "win32":
9
+ // The empty string is `start`'s title argument; without it a quoted URL becomes the title.
10
+ return { cmd: "cmd", args: ["/c", "start", "", url] };
11
+ case "linux":
12
+ return { cmd: "xdg-open", args: [url] };
13
+ default:
14
+ return null;
15
+ }
16
+ }
17
+ /**
18
+ * Best effort: open `url` in the developer's browser. Never throws and never blocks —
19
+ * returns false when we have no opener for this platform or spawning fails, so the
20
+ * caller can print the URL instead.
21
+ */
22
+ export function openUrl(url, platform = process.platform, spawnImpl = nodeSpawn) {
23
+ const target = opener(platform, url);
24
+ if (!target)
25
+ return false;
26
+ try {
27
+ spawnImpl(target.cmd, target.args, { stdio: "ignore", detached: true }).unref();
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
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.202609230009";
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.202609230009",
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.202609230009",
38
+ "@namiml/sdk-core": "3.4.11-dev.202609231629",
39
39
  "commander": "^15",
40
40
  "env-paths": "^4",
41
41
  "ink": "^6.0.0",