@asturiu/v0-sdk 0.0.0-stage → 1.0.0
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/CHANGELOG.md +42 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +82 -2
- package/contract/assertion-vectors.json +90 -0
- package/contract/schemas/branding.schema.json +46 -0
- package/contract/schemas/definitions.schema.json +376 -0
- package/contract/schemas/portal.schema.json +56 -0
- package/contract/tenant-api-routes.json +165 -0
- package/dist/branding.d.ts +131 -0
- package/dist/branding.d.ts.map +1 -0
- package/dist/branding.js +206 -0
- package/dist/branding.js.map +1 -0
- package/dist/changesets.d.ts +58 -0
- package/dist/changesets.d.ts.map +1 -0
- package/dist/changesets.js +18 -0
- package/dist/changesets.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +12 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/changesets.d.ts +14 -0
- package/dist/cli/changesets.d.ts.map +1 -0
- package/dist/cli/changesets.js +252 -0
- package/dist/cli/changesets.js.map +1 -0
- package/dist/cli/cli.d.ts +37 -0
- package/dist/cli/cli.d.ts.map +1 -0
- package/dist/cli/cli.js +415 -0
- package/dist/cli/cli.js.map +1 -0
- package/dist/cli/context.d.ts +147 -0
- package/dist/cli/context.d.ts.map +1 -0
- package/dist/cli/context.js +258 -0
- package/dist/cli/context.js.map +1 -0
- package/dist/cli/engine.d.ts +59 -0
- package/dist/cli/engine.d.ts.map +1 -0
- package/dist/cli/engine.js +85 -0
- package/dist/cli/engine.js.map +1 -0
- package/dist/cli/feedback.d.ts +40 -0
- package/dist/cli/feedback.d.ts.map +1 -0
- package/dist/cli/feedback.js +420 -0
- package/dist/cli/feedback.js.map +1 -0
- package/dist/cli/files.d.ts +83 -0
- package/dist/cli/files.d.ts.map +1 -0
- package/dist/cli/files.js +389 -0
- package/dist/cli/files.js.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +8 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/overview.d.ts +6 -0
- package/dist/cli/overview.d.ts.map +1 -0
- package/dist/cli/overview.js +379 -0
- package/dist/cli/overview.js.map +1 -0
- package/dist/cli/parts.d.ts +5 -0
- package/dist/cli/parts.d.ts.map +1 -0
- package/dist/cli/parts.js +414 -0
- package/dist/cli/parts.js.map +1 -0
- package/dist/cli/sources.d.ts +37 -0
- package/dist/cli/sources.d.ts.map +1 -0
- package/dist/cli/sources.js +91 -0
- package/dist/cli/sources.js.map +1 -0
- package/dist/cli/terminal.d.ts +135 -0
- package/dist/cli/terminal.d.ts.map +1 -0
- package/dist/cli/terminal.js +353 -0
- package/dist/cli/terminal.js.map +1 -0
- package/dist/cli/ui-page.d.ts +8 -0
- package/dist/cli/ui-page.d.ts.map +1 -0
- package/dist/cli/ui-page.js +216 -0
- package/dist/cli/ui-page.js.map +1 -0
- package/dist/cli/ui.d.ts +54 -0
- package/dist/cli/ui.d.ts.map +1 -0
- package/dist/cli/ui.js +251 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/client.d.ts +88 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +165 -0
- package/dist/client.js.map +1 -0
- package/dist/contacts.d.ts +43 -0
- package/dist/contacts.d.ts.map +1 -0
- package/dist/contacts.js +46 -0
- package/dist/contacts.js.map +1 -0
- package/dist/definition-set.d.ts +731 -0
- package/dist/definition-set.d.ts.map +1 -0
- package/dist/definition-set.js +659 -0
- package/dist/definition-set.js.map +1 -0
- package/dist/definitions.d.ts +42 -0
- package/dist/definitions.d.ts.map +1 -0
- package/dist/definitions.js +154 -0
- package/dist/definitions.js.map +1 -0
- package/dist/enrolment.d.ts +9 -0
- package/dist/enrolment.d.ts.map +1 -0
- package/dist/enrolment.js +8 -0
- package/dist/enrolment.js.map +1 -0
- package/dist/fill.d.ts +73 -0
- package/dist/fill.d.ts.map +1 -0
- package/dist/fill.js +46 -0
- package/dist/fill.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/notifications.d.ts +33 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +36 -0
- package/dist/notifications.js.map +1 -0
- package/dist/oidc.d.ts +122 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +150 -0
- package/dist/oidc.js.map +1 -0
- package/dist/overlay.d.ts +109 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +58 -0
- package/dist/overlay.js.map +1 -0
- package/dist/passes.d.ts +95 -0
- package/dist/passes.d.ts.map +1 -0
- package/dist/passes.js +140 -0
- package/dist/passes.js.map +1 -0
- package/dist/portal-options.d.ts +65 -0
- package/dist/portal-options.d.ts.map +1 -0
- package/dist/portal-options.js +120 -0
- package/dist/portal-options.js.map +1 -0
- package/dist/reads.d.ts +41 -0
- package/dist/reads.d.ts.map +1 -0
- package/dist/reads.js +44 -0
- package/dist/reads.js.map +1 -0
- package/dist/recovery-words.d.ts +8 -0
- package/dist/recovery-words.d.ts.map +1 -0
- package/dist/recovery-words.js +136 -0
- package/dist/recovery-words.js.map +1 -0
- package/dist/recovery.d.ts +198 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +112 -0
- package/dist/recovery.js.map +1 -0
- package/dist/reveal.d.ts +35 -0
- package/dist/reveal.d.ts.map +1 -0
- package/dist/reveal.js +66 -0
- package/dist/reveal.js.map +1 -0
- package/dist/seal.d.ts +15 -0
- package/dist/seal.d.ts.map +1 -0
- package/dist/seal.js +0 -0
- package/dist/seal.js.map +1 -0
- package/dist/session.d.ts +66 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +61 -0
- package/dist/session.js.map +1 -0
- package/dist/signing-in-mark.d.ts +14 -0
- package/dist/signing-in-mark.d.ts.map +1 -0
- package/dist/signing-in-mark.js +24 -0
- package/dist/signing-in-mark.js.map +1 -0
- package/dist/signing-in.d.ts +22 -0
- package/dist/signing-in.d.ts.map +1 -0
- package/dist/signing-in.js +203 -0
- package/dist/signing-in.js.map +1 -0
- package/dist/tags.d.ts +96 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/tags.js +88 -0
- package/dist/tags.js.map +1 -0
- package/dist/tenant-subscription.d.ts +47 -0
- package/dist/tenant-subscription.d.ts.map +1 -0
- package/dist/tenant-subscription.js +7 -0
- package/dist/tenant-subscription.js.map +1 -0
- package/dist/testing/index.d.ts +125 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +320 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/theme.d.ts +237 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +504 -0
- package/dist/theme.js.map +1 -0
- package/dist/tickets.d.ts +152 -0
- package/dist/tickets.d.ts.map +1 -0
- package/dist/tickets.js +65 -0
- package/dist/tickets.js.map +1 -0
- package/dist/tiers.d.ts +168 -0
- package/dist/tiers.d.ts.map +1 -0
- package/dist/tiers.js +341 -0
- package/dist/tiers.js.map +1 -0
- package/dist/types.d.ts +885 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/urlid.d.ts +50 -0
- package/dist/urlid.d.ts.map +1 -0
- package/dist/urlid.js +78 -0
- package/dist/urlid.js.map +1 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +15 -0
- package/dist/version.js.map +1 -0
- package/make/asturiu.mk +43 -0
- package/package.json +63 -4
- package/src/branding.ts +221 -0
- package/src/changesets.ts +61 -0
- package/src/cli/bin.ts +12 -0
- package/src/cli/changesets.ts +233 -0
- package/src/cli/cli.ts +425 -0
- package/src/cli/context.ts +299 -0
- package/src/cli/engine.ts +112 -0
- package/src/cli/feedback.ts +406 -0
- package/src/cli/files.ts +371 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/overview.ts +343 -0
- package/src/cli/parts.ts +371 -0
- package/src/cli/sources.ts +111 -0
- package/src/cli/terminal.ts +384 -0
- package/src/cli/ui-page.ts +215 -0
- package/src/cli/ui.ts +266 -0
- package/src/client.ts +202 -0
- package/src/contacts.ts +61 -0
- package/src/definition-set.ts +965 -0
- package/src/definitions.ts +156 -0
- package/src/enrolment.ts +10 -0
- package/src/fill.ts +83 -0
- package/src/index.ts +38 -0
- package/src/notifications.ts +55 -0
- package/src/oidc.ts +199 -0
- package/src/overlay.ts +137 -0
- package/src/passes.ts +168 -0
- package/src/portal-options.ts +125 -0
- package/src/reads.ts +71 -0
- package/src/recovery-words.ts +137 -0
- package/src/recovery.ts +269 -0
- package/src/reveal.ts +72 -0
- package/src/seal.ts +0 -0
- package/src/session.ts +93 -0
- package/src/signing-in-mark.ts +23 -0
- package/src/signing-in.ts +213 -0
- package/src/tags.ts +146 -0
- package/src/tenant-subscription.ts +44 -0
- package/src/testing/index.ts +337 -0
- package/src/theme.ts +628 -0
- package/src/tickets.ts +195 -0
- package/src/tiers.ts +397 -0
- package/src/types.ts +847 -0
- package/src/urlid.ts +77 -0
- package/src/version.ts +15 -0
- package/templates/asturiu/branding.json +4 -0
- package/templates/asturiu/definitions.json +23 -0
- package/templates/asturiu/portal.json +16 -0
package/src/cli/files.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repository's files next to what the Engine holds: the Definition set, the branding and the portal options
|
|
3
|
+
* (ADR-029, ADR-038, ADR-039). `definitions`, `branding`, `pull` and `apply`.
|
|
4
|
+
*/
|
|
5
|
+
import { brandingImageProblems, inlineImageBytes } from "../branding.js";
|
|
6
|
+
import { type DefinitionSet, type DefinitionStatus, applyBranding, applyDefinitions, applyPortal, definitionSetHash, definitionStatus, entryTypesOf, readBranding, readPortal } from "../definition-set.js";
|
|
7
|
+
import { PORTAL_PAGES, PORTAL_SWITCHES } from "../portal-options.js";
|
|
8
|
+
import { sanitizeThemeSpec } from "../theme.js";
|
|
9
|
+
import { type CallResult, type Ctx, EXIT, Fail, call, emit, hostOf, needTenantApi, readFailure, sources } from "./context.js";
|
|
10
|
+
import { type Json, imagePaths } from "./sources.js";
|
|
11
|
+
import { type Tone, bytes, plain, plural, truncate } from "./terminal.js";
|
|
12
|
+
|
|
13
|
+
// ================================================================== differences
|
|
14
|
+
|
|
15
|
+
/** Where two JSON values differ, as dotted paths with both sides. */
|
|
16
|
+
export function differences(a: unknown, b: unknown, at = ""): { path: string; a: unknown; b: unknown }[] {
|
|
17
|
+
const isObj = (v: unknown): v is Json => !!v && typeof v === "object" && !Array.isArray(v);
|
|
18
|
+
if (isObj(a) && isObj(b)) return [...new Set([...Object.keys(a), ...Object.keys(b)])].flatMap((k) => differences(a[k], b[k], at ? `${at}.${k}` : k));
|
|
19
|
+
return JSON.stringify(a) === JSON.stringify(b) ? [] : [{ path: at || "(all)", a, b }];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A value as a difference shows it: an inline image by its size, long text cut. */
|
|
23
|
+
function shown(v: unknown): string {
|
|
24
|
+
if (v === undefined) return "(not set)";
|
|
25
|
+
const size = inlineImageBytes(v);
|
|
26
|
+
if (size !== null) return `inline image, ${bytes(size)}`;
|
|
27
|
+
return truncate(JSON.stringify(v), 60);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The theme keys the Engine would not keep. */
|
|
31
|
+
export function themeDrops(theme: unknown): string[] {
|
|
32
|
+
if (!theme || typeof theme !== "object") return [];
|
|
33
|
+
return differences(theme, sanitizeThemeSpec(theme))
|
|
34
|
+
.filter((d) => d.b === undefined)
|
|
35
|
+
.map((d) => d.path);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Reads one of the repository's files; its error instead of a throw. */
|
|
39
|
+
export function readSource(read: () => Json): { value: Json | null; error: string | null } {
|
|
40
|
+
try {
|
|
41
|
+
return { value: read(), error: null };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return { value: null, error: (err as Error).message };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type TabState = { error: string; why: string; status: number } | { source: string; theme?: boolean; redirected?: string[]; stored: Json; differences: { path: string; repository: string; stored: string }[]; source_error?: string };
|
|
48
|
+
export const tabRead = (s: TabState): s is Exclude<TabState, { error: string }> => !("error" in s);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What the repository sends next to what the Engine holds, per tab. A switch or page the repository leaves out is its
|
|
52
|
+
* default (on, in the portal), so it is compared as such; a page an administrator redirected is not the repository's.
|
|
53
|
+
*/
|
|
54
|
+
export async function presentation(ctx: Ctx): Promise<{ branding: TabState; portal: TabState }> {
|
|
55
|
+
const [b, p] = await Promise.all([call(ctx, (fetch, cfg) => readBranding({ ...cfg, fetch })), call(ctx, (fetch, cfg) => readPortal({ ...cfg, fetch }))]);
|
|
56
|
+
const src = sources(ctx);
|
|
57
|
+
const diff = (x: unknown, y: unknown) => differences(x, y).map((d) => ({ path: d.path, repository: shown(d.a), stored: shown(d.b) }));
|
|
58
|
+
|
|
59
|
+
let branding: TabState;
|
|
60
|
+
if (!b.ok) branding = { error: readFailure(b, "branding"), why: b.why, status: b.status };
|
|
61
|
+
else {
|
|
62
|
+
const w = readSource(() => src.branding());
|
|
63
|
+
branding = { source: b.value.source, theme: b.value.branding.theme !== undefined, stored: b.value.branding as Json, differences: w.value ? diff(w.value, b.value.branding) : [], ...(w.error ? { source_error: w.error } : {}) };
|
|
64
|
+
}
|
|
65
|
+
let portal: TabState;
|
|
66
|
+
if (!p.ok) portal = { error: readFailure(p, "portal"), why: p.why, status: p.status };
|
|
67
|
+
else {
|
|
68
|
+
const w = readSource(() => src.portal());
|
|
69
|
+
let differ: { path: string; repository: string; stored: string }[] = [];
|
|
70
|
+
if (w.value) {
|
|
71
|
+
const redirected = p.value.redirected as string[];
|
|
72
|
+
const wanted: Json = Object.fromEntries(PORTAL_SWITCHES.map((k) => [k, w.value?.[k] ?? true]));
|
|
73
|
+
wanted.pages = Object.fromEntries(PORTAL_PAGES.filter((pg) => !redirected.includes(pg) && (p.value.portal.pages as Json | undefined)?.[pg] !== undefined).map((pg) => [pg, w.value?.pages?.[pg] ?? { mode: "portal" }]));
|
|
74
|
+
differ = diff(wanted, p.value.portal);
|
|
75
|
+
}
|
|
76
|
+
portal = { source: p.value.source, redirected: p.value.redirected, stored: p.value.portal as Json, differences: differ, ...(w.error ? { source_error: w.error } : {}) };
|
|
77
|
+
}
|
|
78
|
+
return { branding, portal };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A tab's state without the stored values (inline images make it long). */
|
|
82
|
+
export function withoutStored(s: TabState): Json {
|
|
83
|
+
const { stored: _s, ...rest } = s as Json;
|
|
84
|
+
return rest;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const editedIn = (source: string) => (source === "repository" ? "edited in the repository" : "edited in the Admin UI");
|
|
88
|
+
|
|
89
|
+
/** Both tabs next to the repository's files: who edits each, and every difference as a table. */
|
|
90
|
+
function renderTabs(ctx: Ctx, pres: { branding: TabState; portal: TabState }) {
|
|
91
|
+
const { t } = ctx;
|
|
92
|
+
const src = sources(ctx);
|
|
93
|
+
for (const tab of ["branding", "portal"] as const) {
|
|
94
|
+
const s = pres[tab];
|
|
95
|
+
const label = t.paint.bold(tab === "branding" ? "Branding" : "Portal");
|
|
96
|
+
if (!tabRead(s)) {
|
|
97
|
+
t.item(s.why === "no-key" ? "muted" : "fail", label, s.error);
|
|
98
|
+
t.out();
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const tone: Tone = s.source_error ? "fail" : s.differences.length ? "warn" : s.source === "repository" ? "ok" : "warn";
|
|
102
|
+
t.item(tone, `${label} ${editedIn(s.source)}`, s.source_error ? "" : s.differences.length ? plural(s.differences.length, "difference") : `equal to ${src.files[tab]}`);
|
|
103
|
+
if (s.source_error) t.out(` ${t.paint.red(plain(s.source_error))}`);
|
|
104
|
+
if (s.source !== "repository") t.out(` ${t.paint.gray("apply answers 409 until a tenant administrator hands the tab to the repository")}`);
|
|
105
|
+
if (tab === "branding" && !s.theme) t.out(` ${t.paint.gray("no theme stored: the portal runs on its default look")}`);
|
|
106
|
+
if (tab === "portal" && s.redirected?.length) t.out(` ${t.paint.gray(`${s.redirected.join(", ")} ${s.redirected.length === 1 ? "stays" : "stay"} in the application, where an administrator moved ${s.redirected.length === 1 ? "it" : "them"}`)}`);
|
|
107
|
+
if (s.differences.length) {
|
|
108
|
+
t.table(
|
|
109
|
+
s.differences,
|
|
110
|
+
[
|
|
111
|
+
{ label: "Key", value: (d) => d.path, paint: (x) => t.paint.bold(x) },
|
|
112
|
+
{ label: "Repository", value: (d) => d.repository, flex: true, min: 14 },
|
|
113
|
+
{ label: "Stored", value: (d) => d.stored, flex: true, min: 14, paint: (x) => t.paint.cyan(x) },
|
|
114
|
+
],
|
|
115
|
+
{ indent: 6 },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
t.out();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ================================================================== the Definition set
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The repository's Definition set, its size and its hash: the hash the SDK sends and the Engine stores, so equal
|
|
126
|
+
* hashes mean the live set is the repository's.
|
|
127
|
+
*/
|
|
128
|
+
export async function repositorySet(ctx: Ctx) {
|
|
129
|
+
const read = readSource(() => sources(ctx).definitions());
|
|
130
|
+
if (!read.value) return { set: null, error: read.error, hash: null as string | null, wallets: 0, entries: 0, fields: 0 };
|
|
131
|
+
const set = read.value as DefinitionSet;
|
|
132
|
+
const entries = Object.values(entryTypesOf({ wallets: set.wallets ?? {}, shared_entries: set.shared_entries }));
|
|
133
|
+
const fields = Object.keys(set.shared_fields ?? {}).length + entries.reduce((n, e) => n + Object.keys(e.fields ?? {}).length, 0);
|
|
134
|
+
let hash: string | null = null;
|
|
135
|
+
try {
|
|
136
|
+
hash = await definitionSetHash(set);
|
|
137
|
+
} catch {
|
|
138
|
+
hash = null;
|
|
139
|
+
}
|
|
140
|
+
return { set, error: null, hash, wallets: Object.keys(set.wallets ?? {}).length, entries: entries.length, fields };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Where the repository's set stands: live, waiting for approval, or not sent; null when either side is unknown. */
|
|
144
|
+
export function setState(live: DefinitionStatus | null, hash: string | null): "live" | "waiting" | "differs" | null {
|
|
145
|
+
if (!live || !hash || !live.hash) return null;
|
|
146
|
+
if (live.hash === hash) return "live";
|
|
147
|
+
if (live.waiting.some((w) => w.hash === hash)) return "waiting";
|
|
148
|
+
return "differs";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function readDefinitionStatus(ctx: Ctx): Promise<CallResult<DefinitionStatus>> {
|
|
152
|
+
return call(ctx, (fetch, cfg) => definitionStatus({ ...cfg, fetch }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function cmdDefinitions(ctx: Ctx, args: string[]) {
|
|
156
|
+
const sub = args[0] ?? "status";
|
|
157
|
+
if (sub === "apply") return applyParts(ctx, ["definitions"]);
|
|
158
|
+
if (sub !== "status") throw new Fail(`“definitions ${plain(sub)}” is not a command.`, { hint: `${ctx.cmd} definitions, or ${ctx.cmd} definitions apply`, code: EXIT.misunderstood });
|
|
159
|
+
const { t } = ctx;
|
|
160
|
+
const src = sources(ctx);
|
|
161
|
+
const [st, repo] = await t.busy("Reading the live Definition set", () => Promise.all([readDefinitionStatus(ctx), repositorySet(ctx)]));
|
|
162
|
+
const live = st.ok ? st.value : null;
|
|
163
|
+
const state = setState(live, repo.hash);
|
|
164
|
+
if (ctx.flags.check && state !== "live") ctx.exit = EXIT.failed;
|
|
165
|
+
if (ctx.json) return emit(ctx, { live: st.ok ? live : { error: readFailure(st, "definitions") }, repository: { file: src.files.definitions, hash: repo.hash, wallet_types: repo.wallets, entry_types: repo.entries, fields: repo.fields, ...(repo.error ? { error: repo.error } : {}) }, state });
|
|
166
|
+
|
|
167
|
+
t.banner("Definitions", [`live at ${hostOf(ctx.baseUrl)}`, `next to ${src.label}`]);
|
|
168
|
+
const short = (h: string | null | undefined) => (h ? `${h.slice(0, 12)}${t.sym.ellipsis}` : "—");
|
|
169
|
+
const rows: [string, string | string[]][] = [];
|
|
170
|
+
if (live) rows.push(["Live", [`${t.mark(live.source === "repository" ? "ok" : "warn")} revision ${t.paint.bold(String(live.revision))} ${t.sym.dot} ${editedIn(live.source)} ${t.paint.gray(live.auto_apply === "safe" ? "safe changes apply on their own" : "every change waits for approval")}`, ` ${t.paint.gray(`hash ${short(live.hash)}`)}`]]);
|
|
171
|
+
else if (!st.ok) rows.push(["Live", `${t.mark(st.why === "no-key" ? "muted" : "fail")} ${readFailure(st, "definitions")}`]);
|
|
172
|
+
if (repo.set) rows.push(["Repository", [`${t.mark("info")} ${plural(repo.wallets, "Wallet type")} ${t.sym.dot} ${plural(repo.entries, "Entry type")} ${t.sym.dot} ${plural(repo.fields, "Field")}`, ` ${t.paint.gray(`hash ${short(repo.hash)} ${t.sym.dot} ${src.files.definitions}`)}`]]);
|
|
173
|
+
else rows.push(["Repository", `${t.mark("fail")} ${plain(repo.error)}`]);
|
|
174
|
+
for (const w of live?.waiting ?? []) rows.push(["Waiting", [`${t.mark("warn")} plan ${t.paint.bold(plain(w.plan_id))} ${t.sym.dot} since ${w.created_at.slice(0, 10)}${w.hash === repo.hash ? ` ${t.sym.dot} the repository's set` : ""}`, ` ${t.paint.gray(`hash ${short(w.hash)}`)}`]]);
|
|
175
|
+
t.pairs(rows, { width: 14 });
|
|
176
|
+
t.out();
|
|
177
|
+
const steps: [string, string][] = [];
|
|
178
|
+
if (state === "live") t.item("ok", t.paint.bold("The live set is the repository's."));
|
|
179
|
+
else if (state === "waiting") t.item("warn", t.paint.bold("The repository's set waits for a tenant administrator's approval."), "the Admin UI, your Tenant › Definitions");
|
|
180
|
+
else if (state === "differs") {
|
|
181
|
+
t.item("warn", t.paint.bold("The live set is not the repository's."), live?.source === "repository" ? "it goes out at the next start" : "");
|
|
182
|
+
steps.push([`${ctx.cmd} definitions apply`, "send it now"]);
|
|
183
|
+
}
|
|
184
|
+
if (live && live.source !== "repository") t.out(` ${t.paint.gray(`${t.sym.arrow} apply answers 409 until a tenant administrator sets the Definitions to be edited in the repository`)}`);
|
|
185
|
+
t.next(steps);
|
|
186
|
+
t.out();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ================================================================== branding and portal
|
|
190
|
+
|
|
191
|
+
export async function cmdBranding(ctx: Ctx, args: string[]) {
|
|
192
|
+
const sub = args[0] ?? "status";
|
|
193
|
+
if (sub === "apply") return applyParts(ctx, ["branding", "portal"]);
|
|
194
|
+
if (sub !== "status") throw new Fail(`“branding ${plain(sub)}” is not a command.`, { hint: `${ctx.cmd} branding, or ${ctx.cmd} branding apply`, code: EXIT.misunderstood });
|
|
195
|
+
const { t } = ctx;
|
|
196
|
+
const src = sources(ctx);
|
|
197
|
+
const pres = await t.busy("Reading the Branding and Portal tabs", () => presentation(ctx));
|
|
198
|
+
const total = [pres.branding, pres.portal].reduce((n, s) => n + (tabRead(s) ? s.differences.length : 0), 0);
|
|
199
|
+
const unread = !tabRead(pres.branding) || !tabRead(pres.portal);
|
|
200
|
+
if (ctx.flags.check && (total || unread)) ctx.exit = EXIT.failed;
|
|
201
|
+
if (ctx.json) return emit(ctx, { branding: withoutStored(pres.branding), portal: withoutStored(pres.portal), differences: total });
|
|
202
|
+
|
|
203
|
+
t.banner("Branding and portal", [`stored at ${hostOf(ctx.baseUrl)}`, `next to ${src.label}`]);
|
|
204
|
+
renderTabs(ctx, pres);
|
|
205
|
+
if (!total && !unread) t.item("ok", "The stored tabs are what the repository sends.");
|
|
206
|
+
t.next(
|
|
207
|
+
total
|
|
208
|
+
? [
|
|
209
|
+
[`${ctx.cmd} branding apply`, `make the stored tabs what ${src.label} says`],
|
|
210
|
+
[`${ctx.cmd} pull`, `or take the stored state into ${src.label}`],
|
|
211
|
+
]
|
|
212
|
+
: [],
|
|
213
|
+
);
|
|
214
|
+
t.out();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function cmdPull(ctx: Ctx) {
|
|
218
|
+
const { t, flags } = ctx;
|
|
219
|
+
needTenantApi(ctx, "nothing to pull.");
|
|
220
|
+
const src = sources(ctx);
|
|
221
|
+
const pres = await t.busy("Reading the Branding and Portal tabs", () => presentation(ctx));
|
|
222
|
+
const branding = pres.branding;
|
|
223
|
+
const portal = pres.portal;
|
|
224
|
+
if (!tabRead(branding)) throw new Fail(`The branding could not be read: ${branding.error.replace(/^not read: /, "")}.`);
|
|
225
|
+
if (!tabRead(portal)) throw new Fail(`The portal could not be read: ${portal.error.replace(/^not read: /, "")}.`);
|
|
226
|
+
const total = branding.differences.length + portal.differences.length;
|
|
227
|
+
const strip = withoutStored;
|
|
228
|
+
|
|
229
|
+
if (!ctx.json) {
|
|
230
|
+
t.banner("Pull", [`stored at ${hostOf(ctx.baseUrl)}`, `into ${src.label}`]);
|
|
231
|
+
renderTabs(ctx, pres);
|
|
232
|
+
}
|
|
233
|
+
if (!total || !src.write) {
|
|
234
|
+
if (ctx.json) return emit(ctx, { branding: strip(branding), portal: strip(portal), written: [] });
|
|
235
|
+
if (!src.write && total) t.callout("info", "Copy these into the repository's files by hand", [`This repository's configuration has no way to write ${src.label}.`]);
|
|
236
|
+
else t.item("ok", "Nothing to take in.");
|
|
237
|
+
t.out();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let write = flags.write === true || flags.yes === true;
|
|
242
|
+
if (!write && !flags["dry-run"] && !ctx.json) write = (await t.confirm(`Write the stored state into ${src.label}? It replaces what is there.`, false)) === true;
|
|
243
|
+
if (!write) {
|
|
244
|
+
if (ctx.json) return emit(ctx, { branding: strip(branding), portal: strip(portal), written: [] });
|
|
245
|
+
t.next([[`${ctx.cmd} pull --write`, `write the stored state into ${src.label}`]]);
|
|
246
|
+
t.out();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const written = src.write({ branding: branding.stored, portal: portal.stored });
|
|
250
|
+
if (ctx.json) return emit(ctx, { branding: strip(branding), portal: strip(portal), written });
|
|
251
|
+
for (const f of written) t.item("ok", `Wrote ${f}`);
|
|
252
|
+
t.out(` ${t.paint.gray(`${t.sym.arrow} Review the diff${src.afterWrite ? `, then run ${src.afterWrite}` : ""}.`)}`);
|
|
253
|
+
t.out();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ================================================================== apply
|
|
257
|
+
|
|
258
|
+
export async function cmdApply(ctx: Ctx, args: string[]) {
|
|
259
|
+
const named = args.map((a) => a.toLowerCase());
|
|
260
|
+
const known = ["definitions", "branding", "portal"];
|
|
261
|
+
const unknown = named.filter((a) => !known.includes(a));
|
|
262
|
+
if (unknown.length) throw new Fail(`“apply ${plain(unknown[0])}”: apply sends definitions, branding and portal.`, { hint: `${ctx.cmd} apply, or ${ctx.cmd} apply definitions`, code: EXIT.misunderstood });
|
|
263
|
+
return applyParts(ctx, (named.length ? named : known) as ("definitions" | "branding" | "portal")[]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Sends the parts named, as the server start does, and shows each outcome. Exits 1 when one is refused or not sent. */
|
|
267
|
+
export async function applyParts(ctx: Ctx, parts: ("definitions" | "branding" | "portal")[]) {
|
|
268
|
+
const { t } = ctx;
|
|
269
|
+
needTenantApi(ctx, "nothing sent.");
|
|
270
|
+
const src = sources(ctx);
|
|
271
|
+
const want = new Set(parts);
|
|
272
|
+
const notes: { level: string; message: string; detail?: unknown }[] = [];
|
|
273
|
+
const log = {
|
|
274
|
+
info: (m: string, d?: Record<string, unknown>) => notes.push({ level: "info", message: m, detail: d }),
|
|
275
|
+
warn: (m: string, d?: Record<string, unknown>) => notes.push({ level: "warn", message: m, detail: d }),
|
|
276
|
+
error: (m: string, d?: Record<string, unknown>) => notes.push({ level: "error", message: m, detail: d }),
|
|
277
|
+
};
|
|
278
|
+
const set = want.has("definitions") ? readSource(() => src.definitions()) : null;
|
|
279
|
+
const branding = want.has("branding") ? readSource(() => src.branding()) : null;
|
|
280
|
+
const portal = want.has("portal") ? readSource(() => src.portal()) : null;
|
|
281
|
+
const drops = branding?.value ? themeDrops(branding.value.theme) : [];
|
|
282
|
+
|
|
283
|
+
const [d, b, p] = await t.busy(`Sending to ${hostOf(ctx.baseUrl)}`, () =>
|
|
284
|
+
Promise.all([
|
|
285
|
+
set?.value ? call(ctx, (fetch, cfg) => applyDefinitions({ ...cfg, set: set.value as DefinitionSet, fetch, log })) : null,
|
|
286
|
+
branding?.value ? call(ctx, (fetch, cfg) => applyBranding({ ...cfg, branding: branding.value as Json, fetch, log })) : null,
|
|
287
|
+
portal?.value ? call(ctx, (fetch, cfg) => applyPortal({ ...cfg, portal: portal.value as Json, fetch, log })) : null,
|
|
288
|
+
]),
|
|
289
|
+
);
|
|
290
|
+
const outcome = (r: CallResult<unknown> | null, read: { error: string | null } | null): Json => {
|
|
291
|
+
if (!r) return { status: "error", message: read?.error ?? "not read" };
|
|
292
|
+
if (!r.ok) return { status: "error", message: r.message, http: r.status };
|
|
293
|
+
const v = r.value as Json;
|
|
294
|
+
if (v.status === "error") return { ...v, message: r.network || v.message, http: r.status };
|
|
295
|
+
return v;
|
|
296
|
+
};
|
|
297
|
+
const results: Record<string, Json> = {};
|
|
298
|
+
if (set) results.definitions = outcome(d, set);
|
|
299
|
+
if (branding) results.branding = outcome(b, branding);
|
|
300
|
+
if (portal) results.portal = outcome(p, portal);
|
|
301
|
+
const bad = Object.values(results).some((r) => r.status === "error" || r.status === "refused");
|
|
302
|
+
ctx.exit = bad ? EXIT.failed : EXIT.done;
|
|
303
|
+
if (ctx.json) return emit(ctx, { ...results, ...(drops.length ? { theme_dropped: drops } : {}) });
|
|
304
|
+
|
|
305
|
+
const order = ["definitions", "branding", "portal"].filter((x) => want.has(x as "definitions"));
|
|
306
|
+
const what = order.length === 3 ? "Apply" : `Apply ${order.length === 1 ? order[0] : `${order.slice(0, -1).join(", ")} and ${order[order.length - 1]}`}`;
|
|
307
|
+
t.banner(what, [`from ${src.label}`, `to ${hostOf(ctx.baseUrl)}`]);
|
|
308
|
+
const tone: Record<string, Tone> = { applied: "ok", unchanged: "ok", waiting: "warn", refused: "fail", error: "fail" };
|
|
309
|
+
const why = (r: Json, scope: string) => {
|
|
310
|
+
if (r.http === 409) return `${plain(r.message)} ${t.sym.dot} still edited in the Admin UI: a tenant administrator hands it to the repository`;
|
|
311
|
+
if (r.http === 404) return `${plain(r.message)} ${t.sym.dot} the key lacks the scope “${scope}”`;
|
|
312
|
+
return plain(r.message);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const rd = results.definitions;
|
|
316
|
+
if (rd) {
|
|
317
|
+
const changes = (s: Json | undefined) => (s ? ` ${t.sym.dot} ${plural(s.changes, "change")}` : "");
|
|
318
|
+
const text: Record<string, string> = {
|
|
319
|
+
applied: `applied ${t.sym.dot} revision ${rd.revision}${changes(rd.summary)}`,
|
|
320
|
+
unchanged: `unchanged ${t.sym.dot} revision ${rd.revision}`,
|
|
321
|
+
waiting: rd.reverted_in ? `waits: an administrator reverted it in revision ${rd.reverted_in}` : `waits for a tenant administrator's approval${rd.summary ? ` ${t.sym.dot} ${plural(rd.summary.unsafe, "change")} ${rd.summary.unsafe === 1 ? "touches" : "touch"} existing data` : ""}`,
|
|
322
|
+
refused: "refused",
|
|
323
|
+
error: `not sent ${t.sym.dot} ${why(rd, "definitions")}`,
|
|
324
|
+
};
|
|
325
|
+
t.item(tone[rd.status] ?? "info", `${t.paint.bold("Definitions".padEnd(12))} ${text[rd.status] ?? plain(rd.status)}`);
|
|
326
|
+
if (rd.status === "waiting" && rd.approve_url) t.out(` ${t.paint.gray("approve at")} ${t.link(plain(rd.approve_url), rd.approve_url)}`);
|
|
327
|
+
if (rd.status === "waiting" && rd.reverted_in) t.out(` ${t.paint.gray("revert the same change in the repository, or have the waiting plan approved")}`);
|
|
328
|
+
if ((rd.status === "applied" || rd.status === "waiting") && rd.changes?.length) {
|
|
329
|
+
const shownChanges: Json[] = rd.changes.slice(0, 20);
|
|
330
|
+
t.table(
|
|
331
|
+
shownChanges,
|
|
332
|
+
[
|
|
333
|
+
{ label: "Address", value: (c) => c.address, paint: (x) => t.paint.bold(x) },
|
|
334
|
+
{ label: "Change", value: (c) => String(c.change).replace(/_/g, " "), flex: true, min: 12 },
|
|
335
|
+
...(shownChanges.some((c) => c.in_use?.length) ? [{ label: "In use", value: (c: Json) => (c.in_use?.length ? String(c.in_use.reduce((n: number, u: { count: number }) => n + u.count, 0)) : ""), align: "right" as const, paint: (x: string) => t.paint.gray(x) }] : []),
|
|
336
|
+
{ label: "Safe", value: (c) => (c.safe ? t.sym.ok : t.sym.warn), paint: (x) => (x === t.sym.ok ? t.paint.green(x) : t.paint.yellow(x)) },
|
|
337
|
+
],
|
|
338
|
+
{ indent: 6 },
|
|
339
|
+
);
|
|
340
|
+
if (rd.changes.length > shownChanges.length) t.out(` ${t.paint.gray(`and ${rd.changes.length - shownChanges.length} more (--json lists them all)`)}`);
|
|
341
|
+
}
|
|
342
|
+
for (const reason of rd.reasons ?? []) t.out(` ${t.paint.red(t.sym.bullet)} ${reason.address ? `${t.paint.bold(plain(reason.address))} ` : ""}${plain(reason.reason)}`);
|
|
343
|
+
for (const w of rd.warnings ?? []) t.out(` ${t.paint.yellow(t.sym.bullet)} ${w.address ? `${t.paint.bold(plain(w.address))} ` : ""}${plain(w.reason)}`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
for (const [tab, label] of [
|
|
347
|
+
["branding", "Branding"],
|
|
348
|
+
["portal", "Portal"],
|
|
349
|
+
] as const) {
|
|
350
|
+
const r = results[tab];
|
|
351
|
+
if (!r) continue;
|
|
352
|
+
t.item(tone[r.status] ?? "info", `${t.paint.bold(label.padEnd(12))} ${r.status === "error" ? `not sent ${t.sym.dot} ${why(r, tab)}` : plain(r.status)}`);
|
|
353
|
+
for (const pr of r.problems ?? []) t.out(` ${t.paint.red(t.sym.bullet)} ${t.paint.bold((pr.path ?? []).join("."))} ${plain(pr.message)}`);
|
|
354
|
+
if (r.kept?.length) t.out(` ${t.paint.gray(`${r.kept.join(", ")} ${r.kept.length === 1 ? "stays" : "stay"} where an administrator moved ${r.kept.length === 1 ? "it" : "them"}: leave ${r.kept.length === 1 ? "it" : "them"} out of ${src.files.portal}`)}`);
|
|
355
|
+
if (tab === "branding" && drops.length) t.out(` ${t.paint.yellow(t.sym.warn)} ${t.paint.gray(`the Engine drops ${drops.map((x) => `theme.${x}`).join(", ")}: not in the theme reference`)}`);
|
|
356
|
+
}
|
|
357
|
+
if (bad) {
|
|
358
|
+
t.out();
|
|
359
|
+
t.item("fail", t.paint.bold("Not everything was applied."), "nothing above was retried");
|
|
360
|
+
}
|
|
361
|
+
t.out();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The branding's images the portal refuses, and how many it sends inline: for `doctor`. */
|
|
365
|
+
export function brandingFileCheck(value: Json): { problems: { path: string[]; message: string }[]; drops: string[]; images: number } {
|
|
366
|
+
return {
|
|
367
|
+
problems: brandingImageProblems(value),
|
|
368
|
+
drops: themeDrops(value.theme),
|
|
369
|
+
images: imagePaths().filter((p) => p.reduce<any>((o, k) => o?.[k], value) !== undefined).length,
|
|
370
|
+
};
|
|
371
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The command line as a module, for tests and for the local page: `run(argv, options)` answers the exit code. */
|
|
2
|
+
export { COMMAND, COMMANDS, DEFAULT_PREFIX, parseArgs, run, type RunOptions } from "./cli.js";
|
|
3
|
+
export { EXIT, secretIn } from "./context.js";
|
|
4
|
+
export { bundle, type Finding } from "./feedback.js";
|
|
5
|
+
export { promptOf } from "./changesets.js";
|
|
6
|
+
export { createUiHandler, uiAction, type UiAction, type UiRequest, type UiResponse } from "./ui.js";
|
|
7
|
+
export { plain } from "./terminal.js";
|