@namiml/cli 3.4.11-dev.202609222222 → 3.4.11-dev.202609230009
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/use.js +131 -0
- package/dist/config/store.js +20 -1
- package/dist/control-center/lookup.js +42 -0
- package/dist/program.js +13 -0
- package/dist/screens/LoginFlow.js +13 -7
- package/dist/screens/PickOrg.js +10 -0
- package/dist/screens/Shell.js +107 -6
- package/dist/screens/SuccessScreen.js +11 -0
- package/dist/screens/SwitchScreen.js +46 -0
- package/dist/sdk/configure.js +12 -1
- package/dist/sdk/summary.js +12 -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)),
|
|
@@ -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
|
+
}
|
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
|
+
}
|
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.")
|
|
@@ -14,7 +14,13 @@ import { AuthError } from "../auth/errors.js";
|
|
|
14
14
|
export function LoginFlow({ auth, store, host, totpCode, org, onDone, onFail, exitWhenDone = true, phase }) {
|
|
15
15
|
const { exit } = useApp();
|
|
16
16
|
const [step, setStep] = useState({ kind: "email" });
|
|
17
|
-
|
|
17
|
+
// The draft is scoped to the step that typed it: whatever the ordering of the
|
|
18
|
+
// state updates on a step change, one prompt's text can never seed the next.
|
|
19
|
+
const [draft, setDraft] = useState({ step: "email", text: "" });
|
|
20
|
+
const text = draft.step === step.kind ? draft.text : "";
|
|
21
|
+
const setText = (next) => setDraft({ step: step.kind, text: next });
|
|
22
|
+
/** Drop the typed text immediately — a password must not linger in state. */
|
|
23
|
+
const clearDraft = () => setDraft({ step: "busy", text: "" });
|
|
18
24
|
const fail = (error) => {
|
|
19
25
|
const message = error instanceof Error ? error.message : String(error);
|
|
20
26
|
setStep({ kind: "failed", message });
|
|
@@ -56,7 +62,7 @@ export function LoginFlow({ auth, store, host, totpCode, org, onDone, onFail, ex
|
|
|
56
62
|
setStep({ kind: "org", signedIn });
|
|
57
63
|
};
|
|
58
64
|
const submitPassword = async (email, password) => {
|
|
59
|
-
|
|
65
|
+
clearDraft();
|
|
60
66
|
setStep({ kind: "busy", label: "Signing in…" });
|
|
61
67
|
try {
|
|
62
68
|
const result = await auth.login(email, password);
|
|
@@ -74,7 +80,7 @@ export function LoginFlow({ auth, store, host, totpCode, org, onDone, onFail, ex
|
|
|
74
80
|
}
|
|
75
81
|
};
|
|
76
82
|
const submitCode = async (preAuthToken, code) => {
|
|
77
|
-
|
|
83
|
+
clearDraft();
|
|
78
84
|
setStep({ kind: "busy", label: "Checking your code…" });
|
|
79
85
|
try {
|
|
80
86
|
chooseOrg(await auth.confirmMfa(preAuthToken, code));
|
|
@@ -90,10 +96,10 @@ export function LoginFlow({ auth, store, host, totpCode, org, onDone, onFail, ex
|
|
|
90
96
|
if (exitWhenDone && (step.kind === "done" || step.kind === "failed"))
|
|
91
97
|
exit();
|
|
92
98
|
}, [step.kind, exitWhenDone, exit]);
|
|
93
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Sign in to Nami" }), _jsx(Text, { dimColor: true, children: host.baseUrl }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [step.kind === "email" && (_jsxs(Box, { children: [_jsx(Text, { children: "Email: " }), _jsx(TextInput, { value:
|
|
94
|
-
|
|
99
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Sign in to Nami" }), _jsx(Text, { dimColor: true, children: host.baseUrl }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [step.kind === "email" && (_jsxs(Box, { children: [_jsx(Text, { children: "Email: " }), _jsx(TextInput, { value: text, onChange: setText, onSubmit: (v) => { const email = v.trim(); if (email) {
|
|
100
|
+
clearDraft();
|
|
95
101
|
setStep({ kind: "password", email });
|
|
96
|
-
} } })] })), step.kind === "password" && (_jsxs(Box, { flexDirection: "column", children: [step.notice && _jsx(Text, { color: palette.aqua, children: step.notice }), _jsxs(Box, { children: [_jsx(Text, { children: "Password: " }), _jsx(TextInput, { value:
|
|
97
|
-
void submitPassword(step.email, v); } })] })] })), step.kind === "mfa" && (_jsxs(Box, { flexDirection: "column", children: [step.notice && _jsx(Text, { color: palette.aqua, children: step.notice }), _jsxs(Box, { children: [_jsx(Text, { children: "Authenticator code: " }), _jsx(TextInput, { value:
|
|
102
|
+
} } })] })), step.kind === "password" && (_jsxs(Box, { flexDirection: "column", children: [step.notice && _jsx(Text, { color: palette.aqua, children: step.notice }), _jsxs(Box, { children: [_jsx(Text, { children: "Password: " }), _jsx(TextInput, { value: text, onChange: setText, mask: "*", onSubmit: (v) => { if (v)
|
|
103
|
+
void submitPassword(step.email, v); } })] })] })), step.kind === "mfa" && (_jsxs(Box, { flexDirection: "column", children: [step.notice && _jsx(Text, { color: palette.aqua, children: step.notice }), _jsxs(Box, { children: [_jsx(Text, { children: "Authenticator code: " }), _jsx(TextInput, { value: text, onChange: setText, onSubmit: (v) => { const code = v.trim(); if (code)
|
|
98
104
|
void submitCode(step.preAuthToken, code); } })] })] })), step.kind === "org" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Choose an organization:" }), _jsx(SelectInput, { items: step.signedIn.profile.orgs.map((o) => ({ key: o.id, label: o.name, value: o.id })), initialIndex: Math.max(0, step.signedIn.profile.orgs.findIndex((o) => o.id === step.signedIn.profile.defaultOrgId)), onSelect: (item) => { const chosen = step.signedIn.profile.orgs.find((o) => o.id === item.value); void finish(step.signedIn, chosen); } })] })), step.kind === "busy" && _jsx(Thinking, { label: step.label, phase: phase }), step.kind === "done" && (_jsxs(Text, { color: palette.aqua, children: ["Signed in", step.session.orgName ? ` to ${step.session.orgName}` : "", " on ", step.session.host, "."] })), step.kind === "failed" && _jsxs(Text, { color: palette.coral, children: ["Sign-in failed: ", step.message] })] })] }));
|
|
99
105
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx } 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 (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { bold: true, children: "Your account does not belong to any organization yet." }) }));
|
|
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,9 @@ 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 { SuccessScreen } from "./SuccessScreen.js";
|
|
8
11
|
import { deviceById, presetsFor, resolveDevice, toSelection } from "../devices/presets.js";
|
|
9
12
|
import { SelectionHeader } from "./SelectionHeader.js";
|
|
10
13
|
import { Thinking } from "../components/Thinking.js";
|
|
@@ -13,12 +16,17 @@ import { AuthError } from "../auth/errors.js";
|
|
|
13
16
|
import { listApps } from "../control-center/apps.js";
|
|
14
17
|
import { listPlatforms } from "../control-center/platforms.js";
|
|
15
18
|
import { findApp, findPlatform, ResolveError } from "../control-center/resolve.js";
|
|
19
|
+
import { findAppPlatformById, pastedStandIn } from "../control-center/lookup.js";
|
|
16
20
|
import { ConfigureScreen } from "./ConfigureScreen.js";
|
|
17
21
|
import { appFrom, platformFrom, selectionFrom } from "../config/selection.js";
|
|
22
|
+
import { verifyAppPlatform as verifyAppPlatformFn, VerifyError } from "../sdk/verify.js";
|
|
23
|
+
import TextInput from "ink-text-input";
|
|
18
24
|
/**
|
|
19
|
-
* Bare `nami`: sign in if needed → pick app →
|
|
20
|
-
*
|
|
21
|
-
*
|
|
25
|
+
* Bare `nami`: sign in if needed → pick app → platform → device → home.
|
|
26
|
+
* `s` opens the Switch hub, which reaches every axis `nami use` can switch
|
|
27
|
+
* headlessly: a recent selection, app, platform, device, organization, or a
|
|
28
|
+
* pasted App Platform ID. Every path funnels back through `configure`, so the
|
|
29
|
+
* SDK is re-configured in place and each platform keeps its own storage file.
|
|
22
30
|
*/
|
|
23
31
|
export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitWhenDone = true, phase, verify, configure }) {
|
|
24
32
|
const { exit } = useApp();
|
|
@@ -27,6 +35,7 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
|
|
|
27
35
|
const [orgId, setOrgId] = useState(null);
|
|
28
36
|
const step = (name) => void ctx.config.setOnboardingStep(name).catch(() => { });
|
|
29
37
|
const [queries, setQueries] = useState({ app: appQuery, platform: platformQuery, device: deviceQuery });
|
|
38
|
+
const [pasted, setPasted] = useState("");
|
|
30
39
|
const finish = (code) => {
|
|
31
40
|
onExit?.(code);
|
|
32
41
|
if (exitWhenDone)
|
|
@@ -61,6 +70,85 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
|
|
|
61
70
|
}
|
|
62
71
|
setState({ kind: "pick-device", app, platform, current });
|
|
63
72
|
};
|
|
73
|
+
/** Open the Switch hub over the current home state; recent/current come from config.json. */
|
|
74
|
+
const openSwitch = async (home) => {
|
|
75
|
+
const { config } = await ctx.config.load();
|
|
76
|
+
setState({ kind: "switch", home, recent: config.recent, current: config.current });
|
|
77
|
+
};
|
|
78
|
+
/** A recent entry is a selection we already know — rebuild it and reconfigure, no network call. */
|
|
79
|
+
const useRecent = (home, selection) => {
|
|
80
|
+
const sameDevice = (selection.device?.id ?? null) === (home.device?.id ?? null);
|
|
81
|
+
if (selection.appPlatformId === home.platform.id && sameDevice)
|
|
82
|
+
return setState(home);
|
|
83
|
+
const device = selection.device ? deviceById(selection.device.id) ?? null : null;
|
|
84
|
+
step("configure");
|
|
85
|
+
setState({ kind: "configure", app: appFrom(selection), platform: platformFrom(selection), device });
|
|
86
|
+
};
|
|
87
|
+
const openOrgs = async (home) => {
|
|
88
|
+
setState({ kind: "busy", label: "Loading your organizations…" });
|
|
89
|
+
try {
|
|
90
|
+
const stored = await ctx.store.load();
|
|
91
|
+
if (!stored)
|
|
92
|
+
return setState({ kind: "signin" });
|
|
93
|
+
const profile = await ctx.auth.fetchAccount(stored.accessToken);
|
|
94
|
+
setState({ kind: "pick-org", home, orgs: profile.orgs });
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
handleError(error);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
/** Re-scope the token to another org. No re-login; the old selection no longer applies. */
|
|
101
|
+
const switchOrg = async (org) => {
|
|
102
|
+
setState({ kind: "busy", label: `Switching to ${org.name}…` });
|
|
103
|
+
try {
|
|
104
|
+
const stored = await ctx.store.load();
|
|
105
|
+
if (!stored)
|
|
106
|
+
return setState({ kind: "signin" });
|
|
107
|
+
const accessToken = await ctx.auth.selectOrg(stored.refreshToken, org.id);
|
|
108
|
+
await ctx.store.save({ ...stored, accessToken, orgId: org.id, orgName: org.name, savedAt: new Date().toISOString() });
|
|
109
|
+
setOrgName(org.name);
|
|
110
|
+
setOrgId(org.id);
|
|
111
|
+
await ctx.config.clearCurrent().catch(() => { });
|
|
112
|
+
await loadApps();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
handleError(error);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* A pasted App Platform ID: verify it on the public config endpoint, then find
|
|
120
|
+
* its name by scanning the apps this account can see. An id from another org
|
|
121
|
+
* still works — it just shows as a pasted id until the device step.
|
|
122
|
+
*/
|
|
123
|
+
const usePastedId = async (home, raw) => {
|
|
124
|
+
const id = raw.trim();
|
|
125
|
+
if (!id)
|
|
126
|
+
return;
|
|
127
|
+
setState({ kind: "busy", label: "Checking that ID…" });
|
|
128
|
+
try {
|
|
129
|
+
await (verify ?? verifyAppPlatformFn)(ctx.host.baseUrl, id);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error instanceof VerifyError)
|
|
133
|
+
return setState({ kind: "paste-id", home, notice: error.message });
|
|
134
|
+
return handleError(error);
|
|
135
|
+
}
|
|
136
|
+
const named = (await findAppPlatformById(ctx.session, id)) ?? pastedStandIn(id);
|
|
137
|
+
chooseDevice(named.app, named.platform);
|
|
138
|
+
};
|
|
139
|
+
const onSwitchChoice = (home, action) => {
|
|
140
|
+
if (action.kind === "recent")
|
|
141
|
+
return useRecent(home, action.selection);
|
|
142
|
+
if (action.kind === "app")
|
|
143
|
+
return void loadApps(home.app);
|
|
144
|
+
if (action.kind === "platform")
|
|
145
|
+
return void loadPlatforms(home.app, home.platform);
|
|
146
|
+
if (action.kind === "device")
|
|
147
|
+
return setState({ kind: "pick-device", app: home.app, platform: home.platform, current: home.device ?? undefined });
|
|
148
|
+
if (action.kind === "org")
|
|
149
|
+
return void openOrgs(home);
|
|
150
|
+
setState({ kind: "paste-id", home });
|
|
151
|
+
};
|
|
64
152
|
const loadApps = async (current) => {
|
|
65
153
|
setState({ kind: "busy", label: "Loading your apps…" });
|
|
66
154
|
try {
|
|
@@ -120,24 +208,37 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
|
|
|
120
208
|
if (state.kind === "fatal")
|
|
121
209
|
finish(state.code);
|
|
122
210
|
}, [state.kind]);
|
|
211
|
+
useInput((_input, key) => {
|
|
212
|
+
if (state.kind !== "paste-id")
|
|
213
|
+
return;
|
|
214
|
+
if (key.escape) {
|
|
215
|
+
setPasted("");
|
|
216
|
+
setState(state.home);
|
|
217
|
+
}
|
|
218
|
+
}, { isActive: state.kind === "paste-id" });
|
|
123
219
|
useInput((input, key) => {
|
|
124
220
|
if (state.kind !== "home")
|
|
125
221
|
return;
|
|
126
222
|
if (input === "q" || key.escape)
|
|
127
223
|
return finish(0);
|
|
128
224
|
if (input === "s")
|
|
129
|
-
return void
|
|
225
|
+
return void openSwitch(state);
|
|
130
226
|
if (input === "p")
|
|
131
227
|
return void loadPlatforms(state.app, state.platform);
|
|
132
228
|
if (input === "d" && presetsFor(state.platform.type).length > 1)
|
|
133
229
|
return setState({ kind: "pick-device", app: state.app, platform: state.platform, current: state.device ?? undefined });
|
|
230
|
+
if (input === "l")
|
|
231
|
+
return setState({ ...state, launchHint: true });
|
|
134
232
|
}, { isActive: state.kind === "home" });
|
|
135
|
-
|
|
233
|
+
// The hub and its sub-screens keep the home state they came from, so the header
|
|
234
|
+
// keeps showing what is loaded while you choose what to switch to.
|
|
235
|
+
const shown = "home" in state ? state.home : "app" in state ? state : {};
|
|
236
|
+
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 }));
|
|
136
237
|
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) => {
|
|
137
238
|
void ctx.config
|
|
138
239
|
.setCurrent(selectionFrom(state.app, state.platform, state.device ? toSelection(state.device) : null), { host: ctx.host.name, session: { orgId, orgName } })
|
|
139
240
|
.then(() => ctx.config.markOnboardingDone())
|
|
140
241
|
.catch(() => { });
|
|
141
242
|
setState({ kind: "home", app: state.app, platform: state.platform, device: state.device, result });
|
|
142
|
-
}, onBack: () => chooseDevice(state.app, state.platform, state.device ?? undefined) })), state.kind === "home" && (
|
|
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 })] })] }));
|
|
143
244
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { palette } from "../theme.js";
|
|
4
|
+
export const PLACEMENT_LIST_MAX = 8;
|
|
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 }) {
|
|
7
|
+
const { counts, placements } = result;
|
|
8
|
+
const shown = placements.slice(0, PLACEMENT_LIST_MAX);
|
|
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"] })] }));
|
|
11
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/sdk/configure.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Nami, NamiCampaignManager, NamiPaywallManager, storageService } from "@namiml/sdk-core";
|
|
1
|
+
import { Nami, NamiCampaignManager, NamiPaywallManager, NAMI_SDK_VERSION, storageService } from "@namiml/sdk-core";
|
|
2
|
+
import { summarizePlacements } from "./summary.js";
|
|
2
3
|
import { registerTerminalAdapters } from "../adapters/index.js";
|
|
3
4
|
import { FileStorageAdapter, storagePathFor } from "../adapters/file-storage.adapter.js";
|
|
4
5
|
import { configDir } from "../paths.js";
|
|
@@ -38,6 +39,8 @@ export async function configureSdk(input, deps = {}) {
|
|
|
38
39
|
products: safeCount(() => NamiCampaignManager.getAllProducts().length),
|
|
39
40
|
paywalls: safeCount(() => NamiPaywallManager.allPaywalls().length),
|
|
40
41
|
},
|
|
42
|
+
placements: safeList(() => summarizePlacements(NamiCampaignManager.allCampaigns())),
|
|
43
|
+
sdkVersion: NAMI_SDK_VERSION,
|
|
41
44
|
deviceId: safeDeviceId(),
|
|
42
45
|
storagePath,
|
|
43
46
|
device: preset ? toSelection(preset.device) : null,
|
|
@@ -62,6 +65,14 @@ function safeCount(read) {
|
|
|
62
65
|
return 0;
|
|
63
66
|
}
|
|
64
67
|
}
|
|
68
|
+
function safeList(read) {
|
|
69
|
+
try {
|
|
70
|
+
return read();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
65
76
|
function safeDeviceId() {
|
|
66
77
|
try {
|
|
67
78
|
const device = storageService.getDevice();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** One human label per campaign rule: the placement label for label rules, else the rule name. */
|
|
2
|
+
export function summarizePlacements(campaigns) {
|
|
3
|
+
if (!Array.isArray(campaigns))
|
|
4
|
+
return [];
|
|
5
|
+
const labels = new Set();
|
|
6
|
+
for (const c of campaigns) {
|
|
7
|
+
const label = c.type === "label" && typeof c.value === "string" && c.value.trim() ? c.value.trim() : c.name?.trim();
|
|
8
|
+
if (label)
|
|
9
|
+
labels.add(label);
|
|
10
|
+
}
|
|
11
|
+
return [...labels].sort((a, b) => a.localeCompare(b));
|
|
12
|
+
}
|
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.202609230009";
|
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.202609230009",
|
|
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.202609230009",
|
|
39
39
|
"commander": "^15",
|
|
40
40
|
"env-paths": "^4",
|
|
41
41
|
"ink": "^6.0.0",
|