@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 +2 -0
- package/dist/commands/configure.js +3 -2
- package/dist/commands/onboarding.js +12 -2
- package/dist/commands/placements.js +56 -0
- package/dist/config/sdkconfig.js +69 -0
- package/dist/events/bus.js +58 -0
- package/dist/onboarding/run.js +146 -0
- package/dist/placements/launch.js +160 -0
- package/dist/placements/list.js +52 -0
- package/dist/program.js +17 -1
- package/dist/render/action-map.js +101 -0
- package/dist/render/composite.js +161 -0
- package/dist/screens/EventsPane.js +135 -0
- package/dist/screens/LaunchScreen.js +74 -0
- package/dist/screens/LoginFlow.js +1 -1
- package/dist/screens/NewToNami.js +27 -0
- package/dist/screens/PickOrg.js +2 -2
- package/dist/screens/PickPlacement.js +11 -0
- package/dist/screens/Shell.js +126 -13
- package/dist/screens/SuccessScreen.js +2 -2
- package/dist/screens/WelcomeScreen.js +15 -0
- package/dist/sdk/summary.js +24 -2
- package/dist/utils/open-url.js +33 -0
- package/dist/version.js +1 -1
- package/package.json +2 -2
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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,
|
|
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
|
});
|