@namiml/cli 3.4.11-dev.202609222238 → 3.4.11-dev.202609230307
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/use.js +131 -0
- package/dist/config/sdkconfig.js +69 -0
- package/dist/config/store.js +20 -1
- package/dist/control-center/lookup.js +42 -0
- package/dist/onboarding/run.js +146 -0
- package/dist/program.js +22 -1
- package/dist/screens/LoginFlow.js +1 -1
- package/dist/screens/NewToNami.js +27 -0
- package/dist/screens/PickOrg.js +10 -0
- package/dist/screens/Shell.js +137 -8
- package/dist/screens/SwitchScreen.js +46 -0
- package/dist/screens/WelcomeScreen.js +15 -0
- package/dist/sdk/summary.js +10 -0
- package/dist/utils/open-url.js +33 -0
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { apps } from "./commands/apps.js";
|
|
|
11
11
|
import { platforms } from "./commands/platforms.js";
|
|
12
12
|
import { shell } from "./commands/shell.js";
|
|
13
13
|
import { configure } from "./commands/configure.js";
|
|
14
|
+
import { use } from "./commands/use.js";
|
|
14
15
|
import { config } from "./commands/config.js";
|
|
15
16
|
import { onboarding } from "./commands/onboarding.js";
|
|
16
17
|
import { devices } from "./commands/devices.js";
|
|
@@ -23,6 +24,7 @@ const program = createProgram({
|
|
|
23
24
|
apps: async (opts) => apps(opts, await buildContext(opts.host)),
|
|
24
25
|
platforms: async (opts) => platforms(opts, await buildContext(opts.host)),
|
|
25
26
|
configure: async (opts) => configure(opts, await buildContext(opts.host)),
|
|
27
|
+
use: async (opts) => use(opts, await buildContext(opts.host)),
|
|
26
28
|
config: async (opts) => config(opts, await buildContext(opts.host)),
|
|
27
29
|
onboarding: async (opts) => onboarding(opts, await buildContext(opts.host)),
|
|
28
30
|
devices: async (opts) => devices(opts, await buildContext(opts.host)),
|
|
@@ -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,131 @@
|
|
|
1
|
+
import { AuthError } from "../auth/errors.js";
|
|
2
|
+
import { listApps } from "../control-center/apps.js";
|
|
3
|
+
import { listPlatforms } from "../control-center/platforms.js";
|
|
4
|
+
import { findAppPlatformById, pastedStandIn } from "../control-center/lookup.js";
|
|
5
|
+
import { findApp, findPlatform, resolve, ResolveError } from "../control-center/resolve.js";
|
|
6
|
+
import { appFrom, platformFrom, selectionFrom } from "../config/selection.js";
|
|
7
|
+
import { resolveDevice, toSelection } from "../devices/presets.js";
|
|
8
|
+
import { verifyAppPlatform, VerifyError } from "../sdk/verify.js";
|
|
9
|
+
const NOT_SIGNED_IN = "Not signed in. Run `nami login` first.";
|
|
10
|
+
const NO_FLAGS = "nami use needs one of --org, --app, --platform, --app-platform-id or --device. Run nami for the interactive picker.";
|
|
11
|
+
const NO_CURRENT_APP = "No app selected yet. Pass --app too, or run nami once.";
|
|
12
|
+
const summary = (s) => {
|
|
13
|
+
const device = s.device ? ` · ${s.device.displayName}` : "";
|
|
14
|
+
return `Now using ${s.appName} · ${s.platformName} (${s.platformType})${device}`;
|
|
15
|
+
};
|
|
16
|
+
/** `nami use` — record and validate a selection (org, app, platform, app-platform-id or device). Never runs the SDK. */
|
|
17
|
+
export async function use(opts, ctx, deps = {}) {
|
|
18
|
+
const verify = deps.verify ?? verifyAppPlatform;
|
|
19
|
+
const doListApps = deps.listApps ?? listApps;
|
|
20
|
+
const doListPlatforms = deps.listPlatforms ?? listPlatforms;
|
|
21
|
+
if (!opts.org && !opts.app && !opts.platform && !opts.appPlatformId && !opts.device) {
|
|
22
|
+
ctx.err(NO_FLAGS);
|
|
23
|
+
return 2;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
if (opts.org) {
|
|
27
|
+
const stored = await ctx.store.load();
|
|
28
|
+
if (!stored) {
|
|
29
|
+
ctx.err(NOT_SIGNED_IN);
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
const profile = await ctx.auth.fetchAccount(stored.accessToken);
|
|
33
|
+
const org = resolve(profile.orgs, opts.org, "organization");
|
|
34
|
+
const accessToken = await ctx.auth.selectOrg(stored.refreshToken, org.id);
|
|
35
|
+
const next = { ...stored, accessToken, orgId: org.id, orgName: org.name, savedAt: new Date().toISOString() };
|
|
36
|
+
await ctx.store.save(next);
|
|
37
|
+
await ctx.config.setSession({ orgId: org.id, orgName: org.name });
|
|
38
|
+
// Apps are org-scoped: a bare --org switch invalidates whatever app/platform was current.
|
|
39
|
+
if (!opts.app && !opts.appPlatformId)
|
|
40
|
+
await ctx.config.clearCurrent();
|
|
41
|
+
if (!opts.app && !opts.platform && !opts.appPlatformId && !opts.device) {
|
|
42
|
+
if (opts.json) {
|
|
43
|
+
const { config } = await ctx.config.load();
|
|
44
|
+
ctx.out(JSON.stringify(config.current, null, 2));
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
ctx.out(`Now using organization ${org.name}.`);
|
|
48
|
+
}
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
let app;
|
|
53
|
+
let platform;
|
|
54
|
+
if (opts.appPlatformId) {
|
|
55
|
+
await verify(ctx.host.baseUrl, opts.appPlatformId, deps.fetch ?? globalThis.fetch);
|
|
56
|
+
// The id is verified on a public endpoint; naming it needs a session, so a
|
|
57
|
+
// failed lookup degrades to the stand-ins instead of refusing the switch.
|
|
58
|
+
const named = (await findAppPlatformById(ctx.session, opts.appPlatformId, { listApps: doListApps, listPlatforms: doListPlatforms })) ?? pastedStandIn(opts.appPlatformId);
|
|
59
|
+
app = named.app;
|
|
60
|
+
platform = named.platform;
|
|
61
|
+
}
|
|
62
|
+
else if (opts.app) {
|
|
63
|
+
app = findApp(await doListApps(ctx.session), opts.app);
|
|
64
|
+
const platforms = await doListPlatforms(ctx.session, app.id);
|
|
65
|
+
if (opts.platform) {
|
|
66
|
+
platform = findPlatform(platforms, opts.platform);
|
|
67
|
+
}
|
|
68
|
+
else if (platforms.length === 1) {
|
|
69
|
+
platform = platforms[0];
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
ctx.err(`"${app.name}" has more than one platform: ${platforms.map((p) => p.name).join(", ")}. Pass --platform too.`);
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (opts.platform) {
|
|
77
|
+
const { config } = await ctx.config.load();
|
|
78
|
+
if (!config.current) {
|
|
79
|
+
ctx.err(NO_CURRENT_APP);
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
app = appFrom(config.current);
|
|
83
|
+
const platforms = await doListPlatforms(ctx.session, app.id);
|
|
84
|
+
platform = findPlatform(platforms, opts.platform);
|
|
85
|
+
}
|
|
86
|
+
else if (opts.device) {
|
|
87
|
+
// --device with no app/platform/app-platform-id targets the current selection.
|
|
88
|
+
const { config } = await ctx.config.load();
|
|
89
|
+
if (!config.current) {
|
|
90
|
+
ctx.err(NO_CURRENT_APP);
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
app = appFrom(config.current);
|
|
94
|
+
platform = platformFrom(config.current);
|
|
95
|
+
}
|
|
96
|
+
if (!app || !platform) {
|
|
97
|
+
ctx.err(NO_FLAGS);
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
let device = null;
|
|
101
|
+
if (opts.device) {
|
|
102
|
+
const resolved = resolveDevice(platform.type, opts.device);
|
|
103
|
+
device = toSelection(resolved);
|
|
104
|
+
}
|
|
105
|
+
const selection = selectionFrom(app, platform, device);
|
|
106
|
+
const next = await ctx.config.setCurrent(selection, { host: ctx.host.name });
|
|
107
|
+
if (opts.json) {
|
|
108
|
+
ctx.out(JSON.stringify(next.current, null, 2));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
ctx.out(summary(selection));
|
|
112
|
+
ctx.out(`App Platform ID ${selection.appPlatformId} · ${ctx.host.name}`);
|
|
113
|
+
}
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error instanceof ResolveError) {
|
|
118
|
+
ctx.err(error.message);
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
if (error instanceof VerifyError) {
|
|
122
|
+
ctx.err(error.message);
|
|
123
|
+
return error.code === "invalid_id" ? 2 : 1;
|
|
124
|
+
}
|
|
125
|
+
if (error instanceof AuthError) {
|
|
126
|
+
ctx.err(error.message);
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/config/store.js
CHANGED
|
@@ -30,7 +30,8 @@ function assertNoCredentials(value, path = "config") {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
/** Identity of a selection in `recent`: the platform plus the device it was used on. */
|
|
34
|
+
export const recentKey = (s) => `${s.appPlatformId}::${s.device?.id ?? ""}`;
|
|
34
35
|
/** Upgrade older shapes; unknown versions fall back to defaults (with a warning from load()). */
|
|
35
36
|
export function migrate(raw) {
|
|
36
37
|
if (!raw || typeof raw !== "object")
|
|
@@ -111,6 +112,24 @@ export class ConfigStore {
|
|
|
111
112
|
return next;
|
|
112
113
|
});
|
|
113
114
|
}
|
|
115
|
+
/** Forget the active selection (e.g. after switching org); `recent` is untouched. */
|
|
116
|
+
clearCurrent() {
|
|
117
|
+
return this.enqueue(async () => {
|
|
118
|
+
const { config } = await this.load();
|
|
119
|
+
const next = { ...config, current: null };
|
|
120
|
+
await this.save(next);
|
|
121
|
+
return next;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Replace the session block (org context) only. */
|
|
125
|
+
setSession(session) {
|
|
126
|
+
return this.enqueue(async () => {
|
|
127
|
+
const { config } = await this.load();
|
|
128
|
+
const next = { ...config, session };
|
|
129
|
+
await this.save(next);
|
|
130
|
+
return next;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
114
133
|
setOnboardingStep(step) {
|
|
115
134
|
return this.enqueue(async () => {
|
|
116
135
|
const { config } = await this.load();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { listApps } from "./apps.js";
|
|
2
|
+
import { listPlatforms, platformTypeLabel } from "./platforms.js";
|
|
3
|
+
/**
|
|
4
|
+
* Find the app and platform behind an App Platform ID.
|
|
5
|
+
*
|
|
6
|
+
* There is no "platform by id" endpoint in this client, so this walks the apps
|
|
7
|
+
* the session can see. Naming is a convenience on top of the public verify
|
|
8
|
+
* call: any failure (signed out, another org's id, an unreachable list) returns
|
|
9
|
+
* null so the caller can fall back to `pastedStandIn` rather than refuse a valid id.
|
|
10
|
+
*/
|
|
11
|
+
export async function findAppPlatformById(session, appPlatformId, deps = {}) {
|
|
12
|
+
const apps = deps.listApps ?? listApps;
|
|
13
|
+
const platforms = deps.listPlatforms ?? listPlatforms;
|
|
14
|
+
try {
|
|
15
|
+
for (const app of await apps(session)) {
|
|
16
|
+
const platform = (await platforms(session, app.id)).find((p) => p.id === appPlatformId);
|
|
17
|
+
if (platform)
|
|
18
|
+
return { app, platform };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Naming is optional; the id itself was already verified.
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/** What an unnamed App Platform ID looks like until the device step narrows it down. */
|
|
27
|
+
export function pastedStandIn(appPlatformId) {
|
|
28
|
+
return {
|
|
29
|
+
app: { id: appPlatformId, name: "Pasted ID", archived: false, defaultLanguage: null, logo: null },
|
|
30
|
+
platform: {
|
|
31
|
+
id: appPlatformId,
|
|
32
|
+
appId: null,
|
|
33
|
+
name: "App Platform",
|
|
34
|
+
type: "web",
|
|
35
|
+
typeLabel: platformTypeLabel("web"),
|
|
36
|
+
sdkEnabled: true,
|
|
37
|
+
isConfigValid: true,
|
|
38
|
+
archived: false,
|
|
39
|
+
marketplaceAppId: null,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/program.js
CHANGED
|
@@ -79,6 +79,19 @@ 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("use")
|
|
84
|
+
.description("Switch the current organization, app, app platform or device (headless).")
|
|
85
|
+
.option("--org <id-or-name>", "switch organization (re-scopes the access token; clears the current selection)")
|
|
86
|
+
.option("--app <id-or-name>", "app id, exact name, or unique substring")
|
|
87
|
+
.option("--platform <id-or-name>", "platform id, exact name, or unique substring")
|
|
88
|
+
.option("--app-platform-id <uuid>", "select by App Platform ID directly")
|
|
89
|
+
.option("--device <id-or-name>", "target device preset, previewer id or name")
|
|
90
|
+
.option(HOST_FLAG, HOST_HELP)
|
|
91
|
+
.option("--json", "machine-readable result")
|
|
92
|
+
.action(async (_opts, cmd) => {
|
|
93
|
+
process.exitCode = await h.use(cmd.optsWithGlobals());
|
|
94
|
+
});
|
|
82
95
|
program
|
|
83
96
|
.command("devices")
|
|
84
97
|
.description("List the target device presets and the full previewer device list for a platform type.")
|
|
@@ -97,9 +110,17 @@ export function createProgram(h, options = {}) {
|
|
|
97
110
|
});
|
|
98
111
|
program
|
|
99
112
|
.command("onboarding")
|
|
100
|
-
.description("Show the onboarding step,
|
|
113
|
+
.description("Show the onboarding step, start over with --reset, or onboard headlessly with the flags below.")
|
|
101
114
|
.option(HOST_FLAG, HOST_HELP)
|
|
102
115
|
.option("--reset", "forget the current selection, recent list and onboarding progress")
|
|
116
|
+
.option("--org <id-or-name>", "organization to onboard into (re-scopes the access token)")
|
|
117
|
+
.option("--app <id-or-name>", "app id, exact name, or unique substring")
|
|
118
|
+
.option("--platform <id-or-name>", "platform id, exact name, or unique substring")
|
|
119
|
+
.option("--app-platform-id <uuid>", "onboard straight onto an App Platform ID")
|
|
120
|
+
.option("--from-sdkconfig <path>", "read the App Platform ID and host from a test app's sdkconfig.json")
|
|
121
|
+
.option("--device <id-or-name>", "target device preset, previewer id or name")
|
|
122
|
+
.option("-y, --yes", "answer yes to anything the interactive run would ask (e.g. an SDK-disabled platform)")
|
|
123
|
+
.option("--json", "machine-readable result")
|
|
103
124
|
.action(async (_opts, cmd) => {
|
|
104
125
|
process.exitCode = await h.onboarding(cmd.optsWithGlobals());
|
|
105
126
|
});
|
|
@@ -56,7 +56,7 @@ export function LoginFlow({ auth, store, host, totpCode, org, onDone, onFail, ex
|
|
|
56
56
|
return void finish(signedIn, match);
|
|
57
57
|
}
|
|
58
58
|
if (orgs.length === 0)
|
|
59
|
-
return fail(new AuthError("org_forbidden",
|
|
59
|
+
return fail(new AuthError("org_forbidden", `Your account does not belong to any organization yet. Ask an organization admin to invite you, or create one at ${host.baseUrl}.`));
|
|
60
60
|
if (orgs.length === 1)
|
|
61
61
|
return void finish(signedIn, orgs[0]);
|
|
62
62
|
setStep({ kind: "org", signedIn });
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { openUrl as openUrlInBrowser } from "../utils/open-url.js";
|
|
5
|
+
const STEPS = [
|
|
6
|
+
"Create an account in the Control Center",
|
|
7
|
+
"Create an app",
|
|
8
|
+
"Add a platform to it (Apple, Google, Amazon, Roku, Web)",
|
|
9
|
+
"Copy that platform's App Platform ID",
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* The explainer for a developer with no Nami account yet: the four steps that
|
|
13
|
+
* end in an App Platform ID, and the Control Center to do them in. Pure — the
|
|
14
|
+
* only side effect is the browser handoff the caller injects.
|
|
15
|
+
*/
|
|
16
|
+
export function NewToNami({ host, onContinue, onBack, openUrl = openUrlInBrowser }) {
|
|
17
|
+
const [opened, setOpened] = useState(null);
|
|
18
|
+
useInput((input, key) => {
|
|
19
|
+
if (key.escape)
|
|
20
|
+
return onBack();
|
|
21
|
+
if (key.return)
|
|
22
|
+
return onContinue();
|
|
23
|
+
if (input.toLowerCase() === "o")
|
|
24
|
+
setOpened(openUrl(host.baseUrl));
|
|
25
|
+
});
|
|
26
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "New to Nami" }), _jsx(Text, { children: "Four steps in the Control Center and the CLI can connect:" }), STEPS.map((step, i) => (_jsxs(Text, { children: [" ", i + 1, ". ", step] }, step))), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["Control Center: ", host.baseUrl] }) }), opened === true && _jsx(Text, { dimColor: true, children: "Control Center opened." }), opened === false && _jsxs(Text, { children: ["Open ", host.baseUrl, " in your browser."] }), _jsx(Text, { dimColor: true, children: "o open the Control Center \u00B7 Enter continue \u00B7 Esc back" })] }));
|
|
27
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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
|
+
export function PickOrg({ orgs, currentOrgId, onSelect, onCancel }) {
|
|
5
|
+
if (orgs.length === 0) {
|
|
6
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Your account does not belong to any organization yet." }), _jsx(Text, { children: "Ask an organization admin to invite you, or create one in the Control Center." })] }));
|
|
7
|
+
}
|
|
8
|
+
return (_jsx(SelectList, { title: "Choose an organization", items: orgs.map((o) => ({ key: o.id, label: o.name })), initialKey: currentOrgId ?? undefined, onSelect: (key) => { const org = orgs.find((o) => o.id === key); if (org)
|
|
9
|
+
onSelect(org); }, onCancel: onCancel }));
|
|
10
|
+
}
|
package/dist/screens/Shell.js
CHANGED
|
@@ -5,6 +5,10 @@ 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
|
+
import { PickOrg } from "./PickOrg.js";
|
|
9
|
+
import { SwitchScreen } from "./SwitchScreen.js";
|
|
10
|
+
import { WelcomeScreen } from "./WelcomeScreen.js";
|
|
11
|
+
import { NewToNami } from "./NewToNami.js";
|
|
8
12
|
import { SuccessScreen } from "./SuccessScreen.js";
|
|
9
13
|
import { deviceById, presetsFor, resolveDevice, toSelection } from "../devices/presets.js";
|
|
10
14
|
import { SelectionHeader } from "./SelectionHeader.js";
|
|
@@ -14,20 +18,29 @@ import { AuthError } from "../auth/errors.js";
|
|
|
14
18
|
import { listApps } from "../control-center/apps.js";
|
|
15
19
|
import { listPlatforms } from "../control-center/platforms.js";
|
|
16
20
|
import { findApp, findPlatform, ResolveError } from "../control-center/resolve.js";
|
|
21
|
+
import { findAppPlatformById, pastedStandIn } from "../control-center/lookup.js";
|
|
17
22
|
import { ConfigureScreen } from "./ConfigureScreen.js";
|
|
18
23
|
import { appFrom, platformFrom, selectionFrom } from "../config/selection.js";
|
|
24
|
+
import { resolveHost } from "../hosts.js";
|
|
25
|
+
import { verifyAppPlatform as verifyAppPlatformFn, VerifyError } from "../sdk/verify.js";
|
|
26
|
+
import TextInput from "ink-text-input";
|
|
19
27
|
/**
|
|
20
|
-
* Bare `nami`: sign in if needed → pick app →
|
|
21
|
-
*
|
|
22
|
-
*
|
|
28
|
+
* Bare `nami`: sign in if needed → pick app → platform → device → home.
|
|
29
|
+
* `s` opens the Switch hub, which reaches every axis `nami use` can switch
|
|
30
|
+
* headlessly: a recent selection, app, platform, device, organization, or a
|
|
31
|
+
* pasted App Platform ID. Every path funnels back through `configure`, so the
|
|
32
|
+
* SDK is re-configured in place and each platform keeps its own storage file.
|
|
23
33
|
*/
|
|
24
|
-
export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitWhenDone = true, phase, verify, configure }) {
|
|
34
|
+
export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitWhenDone = true, phase, hostWasExplicit = false, verify, configure }) {
|
|
25
35
|
const { exit } = useApp();
|
|
26
36
|
const [state, setState] = useState({ kind: "boot" });
|
|
27
37
|
const [orgName, setOrgName] = useState(null);
|
|
28
38
|
const [orgId, setOrgId] = useState(null);
|
|
29
39
|
const step = (name) => void ctx.config.setOnboardingStep(name).catch(() => { });
|
|
30
40
|
const [queries, setQueries] = useState({ app: appQuery, platform: platformQuery, device: deviceQuery });
|
|
41
|
+
const [pasted, setPasted] = useState("");
|
|
42
|
+
// The paste route may retry on another host after a 404, without re-typing the id.
|
|
43
|
+
const [pasteHost, setPasteHost] = useState(ctx.host);
|
|
31
44
|
const finish = (code) => {
|
|
32
45
|
onExit?.(code);
|
|
33
46
|
if (exitWhenDone)
|
|
@@ -62,6 +75,100 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
|
|
|
62
75
|
}
|
|
63
76
|
setState({ kind: "pick-device", app, platform, current });
|
|
64
77
|
};
|
|
78
|
+
/** Open the Switch hub over the current home state; recent/current come from config.json. */
|
|
79
|
+
const openSwitch = async (home) => {
|
|
80
|
+
const { config } = await ctx.config.load();
|
|
81
|
+
setState({ kind: "switch", home, recent: config.recent, current: config.current });
|
|
82
|
+
};
|
|
83
|
+
/** A recent entry is a selection we already know — rebuild it and reconfigure, no network call. */
|
|
84
|
+
const useRecent = (home, selection) => {
|
|
85
|
+
const sameDevice = (selection.device?.id ?? null) === (home.device?.id ?? null);
|
|
86
|
+
if (selection.appPlatformId === home.platform.id && sameDevice)
|
|
87
|
+
return setState(home);
|
|
88
|
+
const device = selection.device ? deviceById(selection.device.id) ?? null : null;
|
|
89
|
+
step("configure");
|
|
90
|
+
setState({ kind: "configure", app: appFrom(selection), platform: platformFrom(selection), device });
|
|
91
|
+
};
|
|
92
|
+
const openOrgs = async (home) => {
|
|
93
|
+
setState({ kind: "busy", label: "Loading your organizations…" });
|
|
94
|
+
try {
|
|
95
|
+
const stored = await ctx.store.load();
|
|
96
|
+
if (!stored)
|
|
97
|
+
return setState({ kind: "signin" });
|
|
98
|
+
const profile = await ctx.auth.fetchAccount(stored.accessToken);
|
|
99
|
+
setState({ kind: "pick-org", home, orgs: profile.orgs });
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
handleError(error);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
/** Re-scope the token to another org. No re-login; the old selection no longer applies. */
|
|
106
|
+
const switchOrg = async (org) => {
|
|
107
|
+
setState({ kind: "busy", label: `Switching to ${org.name}…` });
|
|
108
|
+
try {
|
|
109
|
+
const stored = await ctx.store.load();
|
|
110
|
+
if (!stored)
|
|
111
|
+
return setState({ kind: "signin" });
|
|
112
|
+
const accessToken = await ctx.auth.selectOrg(stored.refreshToken, org.id);
|
|
113
|
+
await ctx.store.save({ ...stored, accessToken, orgId: org.id, orgName: org.name, savedAt: new Date().toISOString() });
|
|
114
|
+
setOrgName(org.name);
|
|
115
|
+
setOrgId(org.id);
|
|
116
|
+
await ctx.config.clearCurrent().catch(() => { });
|
|
117
|
+
await loadApps();
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
handleError(error);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* A pasted App Platform ID: verify it on the public config endpoint, then find
|
|
125
|
+
* its name by scanning the apps this account can see. An id from another org
|
|
126
|
+
* still works — it just shows as a pasted id until the device step.
|
|
127
|
+
*/
|
|
128
|
+
const usePastedId = async (home, raw, host = pasteHost) => {
|
|
129
|
+
const id = raw.trim();
|
|
130
|
+
if (!id)
|
|
131
|
+
return;
|
|
132
|
+
setPasteHost(host);
|
|
133
|
+
setState({ kind: "busy", label: "Checking that ID…" });
|
|
134
|
+
try {
|
|
135
|
+
await (verify ?? verifyAppPlatformFn)(host.baseUrl, id);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (error instanceof VerifyError) {
|
|
139
|
+
// A first-run id pasted against production is most often a staging id.
|
|
140
|
+
const offer = error.code === "not_found" && host.name === "production" && !hostWasExplicit ? id : undefined;
|
|
141
|
+
const notice = offer ? `${error.message}\nPress s to try staging instead.` : error.message;
|
|
142
|
+
return setState({ kind: "paste-id", home, notice, offerStaging: offer });
|
|
143
|
+
}
|
|
144
|
+
return handleError(error);
|
|
145
|
+
}
|
|
146
|
+
const named = (await findAppPlatformById(ctx.session, id)) ?? pastedStandIn(id);
|
|
147
|
+
chooseDevice(named.app, named.platform);
|
|
148
|
+
};
|
|
149
|
+
const onWelcomeChoice = (choice) => {
|
|
150
|
+
if (choice === "signin") {
|
|
151
|
+
step("signin");
|
|
152
|
+
return setState({ kind: "signin" });
|
|
153
|
+
}
|
|
154
|
+
if (choice === "new")
|
|
155
|
+
return setState({ kind: "new-to-nami" });
|
|
156
|
+
step("configure");
|
|
157
|
+
setState({ kind: "paste-id", home: null });
|
|
158
|
+
};
|
|
159
|
+
const onSwitchChoice = (home, action) => {
|
|
160
|
+
if (action.kind === "recent")
|
|
161
|
+
return useRecent(home, action.selection);
|
|
162
|
+
if (action.kind === "app")
|
|
163
|
+
return void loadApps(home.app);
|
|
164
|
+
if (action.kind === "platform")
|
|
165
|
+
return void loadPlatforms(home.app, home.platform);
|
|
166
|
+
if (action.kind === "device")
|
|
167
|
+
return setState({ kind: "pick-device", app: home.app, platform: home.platform, current: home.device ?? undefined });
|
|
168
|
+
if (action.kind === "org")
|
|
169
|
+
return void openOrgs(home);
|
|
170
|
+
setState({ kind: "paste-id", home });
|
|
171
|
+
};
|
|
65
172
|
const loadApps = async (current) => {
|
|
66
173
|
setState({ kind: "busy", label: "Loading your apps…" });
|
|
67
174
|
try {
|
|
@@ -100,11 +207,18 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
|
|
|
100
207
|
setOrgName(stored?.orgName ?? null);
|
|
101
208
|
setOrgId(stored?.orgId ?? null);
|
|
102
209
|
const signedIn = Boolean(stored || ctx.env.NAMI_TOKEN);
|
|
210
|
+
const { config } = await ctx.config.load();
|
|
211
|
+
const asked = Boolean(queries.app || queries.platform || queries.device);
|
|
212
|
+
// First run only: the step is still `welcome` and nothing has been chosen.
|
|
213
|
+
// Any later step resumes where it left off, so quitting mid-onboarding
|
|
214
|
+
// never re-greets you.
|
|
215
|
+
if (config.onboarding.step === "welcome" && !config.current && !asked) {
|
|
216
|
+
return setState({ kind: "welcome" });
|
|
217
|
+
}
|
|
103
218
|
if (!signedIn) {
|
|
104
219
|
step("signin");
|
|
105
220
|
return setState({ kind: "signin" });
|
|
106
221
|
}
|
|
107
|
-
const { config } = await ctx.config.load();
|
|
108
222
|
// Zero-prompt re-run: a persisted selection (and no --app/--platform override) goes straight to configure.
|
|
109
223
|
if (config.current && !queries.app && !queries.platform) {
|
|
110
224
|
const platform = platformFrom(config.current);
|
|
@@ -121,13 +235,25 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
|
|
|
121
235
|
if (state.kind === "fatal")
|
|
122
236
|
finish(state.code);
|
|
123
237
|
}, [state.kind]);
|
|
238
|
+
useInput((input, key) => {
|
|
239
|
+
if (state.kind !== "paste-id")
|
|
240
|
+
return;
|
|
241
|
+
if (state.offerStaging && input === "s") {
|
|
242
|
+
const id = state.offerStaging;
|
|
243
|
+
return void usePastedId(state.home, id, resolveHost("staging"));
|
|
244
|
+
}
|
|
245
|
+
if (key.escape) {
|
|
246
|
+
setPasted("");
|
|
247
|
+
setState(state.home ?? { kind: "welcome" });
|
|
248
|
+
}
|
|
249
|
+
}, { isActive: state.kind === "paste-id" });
|
|
124
250
|
useInput((input, key) => {
|
|
125
251
|
if (state.kind !== "home")
|
|
126
252
|
return;
|
|
127
253
|
if (input === "q" || key.escape)
|
|
128
254
|
return finish(0);
|
|
129
255
|
if (input === "s")
|
|
130
|
-
return void
|
|
256
|
+
return void openSwitch(state);
|
|
131
257
|
if (input === "p")
|
|
132
258
|
return void loadPlatforms(state.app, state.platform);
|
|
133
259
|
if (input === "d" && presetsFor(state.platform.type).length > 1)
|
|
@@ -135,12 +261,15 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
|
|
|
135
261
|
if (input === "l")
|
|
136
262
|
return setState({ ...state, launchHint: true });
|
|
137
263
|
}, { isActive: state.kind === "home" });
|
|
138
|
-
|
|
264
|
+
// The hub and its sub-screens keep the home state they came from, so the header
|
|
265
|
+
// keeps showing what is loaded while you choose what to switch to.
|
|
266
|
+
const shown = ("home" in state ? state.home : "app" in state ? state : null) ?? {};
|
|
267
|
+
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 }));
|
|
139
268
|
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) => {
|
|
140
269
|
void ctx.config
|
|
141
270
|
.setCurrent(selectionFrom(state.app, state.platform, state.device ? toSelection(state.device) : null), { host: ctx.host.name, session: { orgId, orgName } })
|
|
142
271
|
.then(() => ctx.config.markOnboardingDone())
|
|
143
272
|
.catch(() => { });
|
|
144
273
|
setState({ kind: "home", app: state.app, platform: state.platform, device: state.device, result });
|
|
145
|
-
}, 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 === "fatal" && _jsx(Text, { color: palette.coral, children: state.message })] })] }));
|
|
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 })] })] }));
|
|
146
275
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
import { recentKey as selectionKey } from "../config/store.js";
|
|
5
|
+
/** `recent:` + `ConfigStore`'s own de-dup key, so the two can never drift apart. */
|
|
6
|
+
const recentKey = (s) => `recent:${selectionKey(s)}`;
|
|
7
|
+
const isCurrent = (s, current) => current !== null && s.appPlatformId === current.appPlatformId && (s.device?.id ?? null) === (current.device?.id ?? null);
|
|
8
|
+
export function switchItems({ recent, current, canSwitchDevice, canSwitchOrg, }) {
|
|
9
|
+
const recentItems = recent.map((s) => {
|
|
10
|
+
const hintParts = [s.device?.displayName, isCurrent(s, current) ? "current" : undefined].filter((v) => Boolean(v));
|
|
11
|
+
return {
|
|
12
|
+
key: recentKey(s),
|
|
13
|
+
label: `${s.appName} · ${s.platformName}`,
|
|
14
|
+
badge: s.platformType,
|
|
15
|
+
hint: hintParts.length > 0 ? hintParts.join(" · ") : undefined,
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
const actionItems = [
|
|
19
|
+
{ key: "action:app", label: "Switch app" },
|
|
20
|
+
{ key: "action:platform", label: "Switch platform" },
|
|
21
|
+
...(canSwitchDevice ? [{ key: "action:device", label: "Change device" }] : []),
|
|
22
|
+
...(canSwitchOrg ? [{ key: "action:org", label: "Change organization" }] : []),
|
|
23
|
+
{ key: "action:paste", label: "Paste an App Platform ID" },
|
|
24
|
+
];
|
|
25
|
+
return [...recentItems, ...actionItems];
|
|
26
|
+
}
|
|
27
|
+
/** The Switch hub: recent selections plus app/platform/device/org/paste actions. Pure — never fetches. */
|
|
28
|
+
export function SwitchScreen({ recent, current, orgName, canSwitchDevice, canSwitchOrg, onChoose, onCancel }) {
|
|
29
|
+
const items = switchItems({ recent, current, canSwitchDevice, canSwitchOrg });
|
|
30
|
+
const handleSelect = (key) => {
|
|
31
|
+
if (key === "action:app")
|
|
32
|
+
return onChoose({ kind: "app" });
|
|
33
|
+
if (key === "action:platform")
|
|
34
|
+
return onChoose({ kind: "platform" });
|
|
35
|
+
if (key === "action:device")
|
|
36
|
+
return onChoose({ kind: "device" });
|
|
37
|
+
if (key === "action:org")
|
|
38
|
+
return onChoose({ kind: "org" });
|
|
39
|
+
if (key === "action:paste")
|
|
40
|
+
return onChoose({ kind: "paste" });
|
|
41
|
+
const selection = recent.find((s) => recentKey(s) === key);
|
|
42
|
+
if (selection)
|
|
43
|
+
onChoose({ kind: "recent", selection });
|
|
44
|
+
};
|
|
45
|
+
return (_jsxs(Box, { flexDirection: "column", children: [orgName && _jsx(Text, { dimColor: true, children: orgName }), _jsx(SelectList, { title: "Switch", items: items, initialKey: "action:app", onSelect: handleSelect, onCancel: onCancel, footer: "Esc to go back" })] }));
|
|
46
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/sdk/summary.js
CHANGED
|
@@ -10,3 +10,13 @@ export function summarizePlacements(campaigns) {
|
|
|
10
10
|
}
|
|
11
11
|
return [...labels].sort((a, b) => a.localeCompare(b));
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The two lines every non-JSON configure path prints. `nami configure`, `nami
|
|
15
|
+
* onboarding` and any future caller share this so their output cannot drift.
|
|
16
|
+
*/
|
|
17
|
+
export function configureSummaryLines(result) {
|
|
18
|
+
return [
|
|
19
|
+
`Configured ${result.appPlatformId} on ${result.host}: ${result.configureState}${result.sdkEnabled ? "" : " (SDK disabled)"}${result.device ? ` · ${result.device.displayName}` : ""}`,
|
|
20
|
+
`placements ${result.counts.placements} · products ${result.counts.products} · paywalls ${result.counts.paywalls}${result.deviceId ? ` · device ${result.deviceId}` : ""}`,
|
|
21
|
+
];
|
|
22
|
+
}
|
|
@@ -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.
|
|
2
|
+
export const NAMI_CLI_VERSION = "3.4.11-dev.202609230307";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@namiml/cli",
|
|
3
|
-
"version": "3.4.11-dev.
|
|
3
|
+
"version": "3.4.11-dev.202609230307",
|
|
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.
|
|
38
|
+
"@namiml/sdk-core": "3.4.11-dev.202609230307",
|
|
39
39
|
"commander": "^15",
|
|
40
40
|
"env-paths": "^4",
|
|
41
41
|
"ink": "^6.0.0",
|