@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
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every command of `asturiu-v0-tenant` shares: the run's context, failures the person can act on, the Tenant
|
|
3
|
+
* API through the SDK's own calls, and the repository's local state. The Tenant key is read from the environment and
|
|
4
|
+
* never printed (ADR-074 §14).
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import * as sdk from "../index.js";
|
|
11
|
+
import { TenantApiError, parseTenantKey } from "../definition-set.js";
|
|
12
|
+
import { type Json, type Sources, folderSources } from "./sources.js";
|
|
13
|
+
import { type Terminal, plain } from "./terminal.js";
|
|
14
|
+
|
|
15
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
16
|
+
export type Env = Record<string, string | undefined>;
|
|
17
|
+
|
|
18
|
+
/** What a repository may set in `asturiu.config.mjs` (or `asturiu/config.mjs`); every key is optional. */
|
|
19
|
+
export interface Config {
|
|
20
|
+
/** The application's name in headings and bundled reports (default: the Tenant's slug). */
|
|
21
|
+
product?: string;
|
|
22
|
+
/** Where the Definition set, branding and portal options live (default: `asturiu/`). `kit` is the SDK, for configurations that call it. */
|
|
23
|
+
sources?: Sources | ((o: { root: string; kit: typeof sdk }) => Sources);
|
|
24
|
+
/** The Tenant API when ASTURIU_TENANT_API_URL is not set. */
|
|
25
|
+
tenantApiUrl?: string;
|
|
26
|
+
/** Where `changesets pull` puts documents (default: `asturiu/changesets`). */
|
|
27
|
+
changesetsDir?: string;
|
|
28
|
+
/** The file with the Asturiu section (default: `AGENTS.md`). */
|
|
29
|
+
agentsFile?: string;
|
|
30
|
+
/** The changesets the repository records as done (default: up to the section's `changeset=`). */
|
|
31
|
+
recorded?: (root: string) => Set<number>;
|
|
32
|
+
/** The area of a finding that names none (default: `application`). */
|
|
33
|
+
feedbackArea?: string;
|
|
34
|
+
/** More patterns a report must not hold (a session cookie's name). */
|
|
35
|
+
secrets?: RegExp[];
|
|
36
|
+
/** How the person runs this, for the hints (default: guessed from the package manager). */
|
|
37
|
+
command?: string;
|
|
38
|
+
/** Set by `loadConfig`: where the configuration was read. */
|
|
39
|
+
file?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Ctx {
|
|
43
|
+
root: string;
|
|
44
|
+
t: Terminal;
|
|
45
|
+
json: boolean;
|
|
46
|
+
flags: Record<string, string | true>;
|
|
47
|
+
env: Env;
|
|
48
|
+
config: Config;
|
|
49
|
+
/** The command prefix for hints: `pnpm asturiu`, `npm run asturiu --`, `npx --no-install asturiu-v0-tenant`. */
|
|
50
|
+
cmd: string;
|
|
51
|
+
cfg: { key: string; baseUrl: string } | null;
|
|
52
|
+
key: string | null;
|
|
53
|
+
baseUrl: string | null;
|
|
54
|
+
fetch: FetchLike | undefined;
|
|
55
|
+
keyExpires: string | null;
|
|
56
|
+
product: string;
|
|
57
|
+
_sources: Sources | null;
|
|
58
|
+
/** The exit code: 0 done, 1 failed or refused, 2 not understood, 3 done here but not reported to the Engine. */
|
|
59
|
+
exit?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The exit codes (ADR-074 §5). */
|
|
63
|
+
export const EXIT = { done: 0, failed: 1, misunderstood: 2, notReported: 3 } as const;
|
|
64
|
+
|
|
65
|
+
/** A failure the person can act on: a title, what happened, what to do. */
|
|
66
|
+
export class Fail extends Error {
|
|
67
|
+
hint?: string;
|
|
68
|
+
lines: string[];
|
|
69
|
+
code: number;
|
|
70
|
+
data?: Json;
|
|
71
|
+
constructor(message: string, o: { hint?: string; lines?: string[]; code?: number; data?: Json } = {}) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.hint = o.hint;
|
|
74
|
+
this.lines = o.lines ?? [];
|
|
75
|
+
this.code = o.code ?? EXIT.failed;
|
|
76
|
+
this.data = o.data;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The repository's configuration: `asturiu.config.mjs` at its root, else `asturiu/config.mjs`, else none. */
|
|
81
|
+
export async function loadConfig(root: string): Promise<Config> {
|
|
82
|
+
for (const name of ["asturiu.config.mjs", path.join("asturiu", "config.mjs")]) {
|
|
83
|
+
const file = path.join(root, name);
|
|
84
|
+
if (!existsSync(file)) continue;
|
|
85
|
+
const mod = (await import(pathToFileURL(file).href)) as { default?: Config } & Config;
|
|
86
|
+
return { ...(mod.default ?? mod), file: name };
|
|
87
|
+
}
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** How the person runs this, for the hints: the package script when there is one, else `npx --no-install asturiu-v0-tenant`. */
|
|
92
|
+
export function commandPrefix(env: Env, fallback: string): string {
|
|
93
|
+
const script = env.npm_lifecycle_event;
|
|
94
|
+
const ua = env.npm_config_user_agent ?? "";
|
|
95
|
+
if (!script || script === "npx") return fallback;
|
|
96
|
+
if (ua.startsWith("pnpm")) return `pnpm ${script}`;
|
|
97
|
+
if (ua.startsWith("yarn")) return `yarn ${script}`;
|
|
98
|
+
if (ua.startsWith("bun")) return `bun run ${script}`;
|
|
99
|
+
return `npm run ${script} --`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The Tenant's slug, out of the key's first part (`atk.<slug>.…`): not a secret. */
|
|
103
|
+
export function slugOf(key: string | null): string | null {
|
|
104
|
+
if (!key) return null;
|
|
105
|
+
try {
|
|
106
|
+
return parseTenantKey(key).slug;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The key's id, out of its second part: not a secret either. */
|
|
113
|
+
export function keyIdOf(key: string | null): string | null {
|
|
114
|
+
if (!key) return null;
|
|
115
|
+
try {
|
|
116
|
+
return parseTenantKey(key).kid;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ================================================================== the Tenant API, through the SDK
|
|
123
|
+
|
|
124
|
+
export type CallFailure = { ok: false; why: "no-key" | "no-url" | "no-route" | "network" | "refused"; status: number; network: string; message: string; type: string | null };
|
|
125
|
+
export type CallResult<T> = { ok: true; value: T; status: number; network: string } | CallFailure;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One SDK call, with what the network answered: its HTTP status, or why it could not reach the Tenant API. A
|
|
129
|
+
* `TenantApiError` carries the status itself; the calls that never throw (`applyDefinitions` and the like) are read
|
|
130
|
+
* through the status the wrapped `fetch` saw.
|
|
131
|
+
*/
|
|
132
|
+
export async function call<T>(ctx: Ctx, fn: (fetch: FetchLike, cfg: { key: string; baseUrl: string }) => Promise<T>): Promise<CallResult<T>> {
|
|
133
|
+
if (!ctx.key) return { ok: false, why: "no-key", status: 0, network: "", message: "ASTURIU_TENANT_KEY is not set", type: null };
|
|
134
|
+
if (!ctx.cfg) return { ok: false, why: "no-url", status: 0, network: "", message: "ASTURIU_TENANT_API_URL is not set", type: null };
|
|
135
|
+
const seen = { status: 0, network: "" };
|
|
136
|
+
const cfg = ctx.cfg;
|
|
137
|
+
const tracked: FetchLike = async (input, init) => {
|
|
138
|
+
try {
|
|
139
|
+
const res = await (ctx.fetch ?? ((i: string, n?: RequestInit) => globalThis.fetch(i, n)))(input, init);
|
|
140
|
+
seen.status = res.status;
|
|
141
|
+
const expires = res.headers.get("asturiu-key-expires");
|
|
142
|
+
if (expires) ctx.keyExpires = expires;
|
|
143
|
+
return res;
|
|
144
|
+
} catch (err) {
|
|
145
|
+
seen.network = unreachable(err, cfg.baseUrl);
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
return { ok: true, value: await fn(tracked, cfg), ...seen };
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const status = err instanceof TenantApiError ? err.status : seen.status;
|
|
153
|
+
const message = seen.network || plain(err instanceof Error ? err.message : String(err));
|
|
154
|
+
const why = seen.network ? "network" : status === 404 ? "no-route" : "refused";
|
|
155
|
+
return { ok: false, why, status, network: seen.network, message, type: err instanceof TenantApiError ? err.type : null };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Why the Tenant API could not be reached, in words. */
|
|
160
|
+
export function unreachable(err: unknown, baseUrl: string): string {
|
|
161
|
+
const cause = (err as { cause?: { code?: string; message?: string } })?.cause;
|
|
162
|
+
const code = cause?.code ?? "";
|
|
163
|
+
const words: Record<string, string> = {
|
|
164
|
+
ECONNREFUSED: "nothing answers there",
|
|
165
|
+
ENOTFOUND: "the host is unknown",
|
|
166
|
+
EAI_AGAIN: "the host could not be looked up",
|
|
167
|
+
ETIMEDOUT: "it did not answer in time",
|
|
168
|
+
UND_ERR_CONNECT_TIMEOUT: "it did not answer in time",
|
|
169
|
+
ECONNRESET: "the connection was cut",
|
|
170
|
+
EPERM: "the connection was not permitted here (a sandbox?)",
|
|
171
|
+
EHOSTUNREACH: "the host cannot be reached",
|
|
172
|
+
CERT_HAS_EXPIRED: "its certificate has expired",
|
|
173
|
+
DEPTH_ZERO_SELF_SIGNED_CERT: "its certificate is self-signed",
|
|
174
|
+
UNABLE_TO_VERIFY_LEAF_SIGNATURE: "its certificate could not be verified",
|
|
175
|
+
};
|
|
176
|
+
return `could not reach the Tenant API at ${hostOf(baseUrl)}: ${words[code] ?? plain(cause?.message ?? (err instanceof Error ? err.message : String(err)))}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const hostOf = (url: string | null | undefined): string => {
|
|
180
|
+
if (!url) return "—";
|
|
181
|
+
try {
|
|
182
|
+
return new URL(url).host;
|
|
183
|
+
} catch {
|
|
184
|
+
return url;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** A failed read in a few words, for a status line. */
|
|
189
|
+
export function readFailure(r: { why: string; message: string; status: number }, scope?: string): string {
|
|
190
|
+
if (r.why === "no-key") return "not read: ASTURIU_TENANT_KEY is not set";
|
|
191
|
+
if (r.why === "no-url") return "not read: ASTURIU_TENANT_API_URL is not set";
|
|
192
|
+
if (r.why === "no-route") return scope ? `not read: the key lacks the scope “${scope}”, or the Engine has no such route` : "not read: the Engine has no such route";
|
|
193
|
+
if (r.status === 401) return "not read: the Tenant API refused the key (revoked, expired, or for another Tenant)";
|
|
194
|
+
return `not read: ${r.message}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The repository's sources, made once. */
|
|
198
|
+
export function sources(ctx: Ctx): Sources {
|
|
199
|
+
if (!ctx._sources) {
|
|
200
|
+
const s = ctx.config.sources;
|
|
201
|
+
ctx._sources = typeof s === "function" ? s({ root: ctx.root, kit: sdk }) : (s ?? folderSources({ root: ctx.root }));
|
|
202
|
+
}
|
|
203
|
+
return ctx._sources;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Fails when no key or address is set: for commands that cannot do anything without. */
|
|
207
|
+
export function needTenantApi(ctx: Ctx, what: string): void {
|
|
208
|
+
if (!ctx.key) throw new Fail(`ASTURIU_TENANT_KEY is not set: ${what}`, { lines: ["The Tenant key is made in the Admin UI (your Tenant › Tenant keys) with the scopes the repository needs. Put it in the server's environment, or in .env, as ASTURIU_TENANT_KEY. Never paste it into a chat, a file under version control or a report."], hint: `${ctx.cmd} doctor checks the whole set-up` });
|
|
209
|
+
if (!ctx.cfg) throw new Fail(`ASTURIU_TENANT_API_URL is not set: ${what}`, { lines: ["It is the Tenant API's address, given with your Tenant."] });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** One JSON document on stdout. */
|
|
213
|
+
export function emit(ctx: Ctx, data: unknown): void {
|
|
214
|
+
ctx.t.out(JSON.stringify(data, null, 2));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ================================================================== local state
|
|
218
|
+
|
|
219
|
+
export function places(ctx: Ctx) {
|
|
220
|
+
return {
|
|
221
|
+
state: path.join(ctx.root, "asturiu", ".state.json"),
|
|
222
|
+
queue: path.join(ctx.root, "asturiu", "feedback", "queue"),
|
|
223
|
+
sent: path.join(ctx.root, "asturiu", "feedback", "sent"),
|
|
224
|
+
offline: path.join(ctx.root, "asturiu-feedback"),
|
|
225
|
+
changesets: path.join(ctx.root, ctx.config.changesetsDir ?? path.join("asturiu", "changesets")),
|
|
226
|
+
agents: path.join(ctx.root, ctx.config.agentsFile ?? "AGENTS.md"),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export const rel = (ctx: Ctx, file: string): string => path.relative(ctx.root, file) || ".";
|
|
231
|
+
|
|
232
|
+
export function readState(ctx: Ctx): Json {
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(readFileSync(places(ctx).state, "utf8")) as Json;
|
|
235
|
+
} catch {
|
|
236
|
+
return {};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function writeState(ctx: Ctx, patch: Json): void {
|
|
241
|
+
const file = places(ctx).state;
|
|
242
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
243
|
+
writeFileSync(file, `${JSON.stringify({ ...readState(ctx), ...patch }, null, 2)}\n`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The Engine version and changeset the Asturiu section in AGENTS.md was written for. */
|
|
247
|
+
export function agentsSection(ctx: Ctx): { engine: string; changeset: number } | null {
|
|
248
|
+
try {
|
|
249
|
+
const m = /<!-- asturiu:begin engine=(\S+) changeset=(\d+) -->/.exec(readFileSync(places(ctx).agents, "utf8"));
|
|
250
|
+
return m ? { engine: m[1], changeset: Number(m[2]) } : null;
|
|
251
|
+
} catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The changesets the repository records as done. */
|
|
257
|
+
export function recorded(ctx: Ctx): Set<number> {
|
|
258
|
+
if (ctx.config.recorded) return ctx.config.recorded(ctx.root);
|
|
259
|
+
const upTo = agentsSection(ctx)?.changeset ?? 0;
|
|
260
|
+
return new Set(Array.from({ length: upTo }, (_, i) => i + 1));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function pulledDocuments(ctx: Ctx): Set<string> {
|
|
264
|
+
const dir = places(ctx).changesets;
|
|
265
|
+
return new Set(existsSync(dir) ? readdirSync(dir) : []);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Whether git ignores a path: true, false, or null outside a repository. */
|
|
269
|
+
export function gitIgnores(ctx: Ctx, file: string): boolean | null {
|
|
270
|
+
const r = spawnSync("git", ["check-ignore", "-q", rel(ctx, file)], { cwd: ctx.root, stdio: "ignore" });
|
|
271
|
+
if (r.error || (r.status !== 0 && r.status !== 1)) return null;
|
|
272
|
+
return r.status === 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ================================================================== secrets
|
|
276
|
+
|
|
277
|
+
/** Text that looks like a secret: the Tenant key, a token, a session, a password. Reports and notes never carry one. */
|
|
278
|
+
export function secretIn(ctx: Pick<Ctx, "key" | "config">, text: string): string | null {
|
|
279
|
+
if (ctx.key && text.includes(ctx.key)) return "the Tenant key";
|
|
280
|
+
if (/\batk\.[a-z0-9-]+\.[0-9a-f]{8,}\./i.test(text)) return "a Tenant key";
|
|
281
|
+
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(text)) return "a private key";
|
|
282
|
+
if (/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\./.test(text)) return "a token";
|
|
283
|
+
if (/\b(authorization:\s*(bearer|asturiu-assertion)\s+\S{12,}|x-asturiu-session:\s*\S{12,})/i.test(text)) return "a credential header";
|
|
284
|
+
if (/\b[a-z0-9_]*session=[A-Za-z0-9._~%-]{16,}/i.test(text)) return "a session identifier";
|
|
285
|
+
if (/\b(app_password|client_secret|password)\s*[:=]\s*\S{6,}/i.test(text)) return "a password or client secret";
|
|
286
|
+
for (const re of ctx.config.secrets ?? []) if (re.test(text)) return "a secret this repository names";
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Fails when the text holds a secret: nothing is sent. */
|
|
291
|
+
export function refuseSecrets(ctx: Ctx, ...texts: (string | undefined)[]): void {
|
|
292
|
+
for (const text of texts) {
|
|
293
|
+
const secret = text ? secretIn(ctx, text) : null;
|
|
294
|
+
if (secret) throw new Fail(`The text looks like it holds ${secret}. Nothing was sent.`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** A flag's string value. */
|
|
299
|
+
export const str = (v: string | true | undefined): string | undefined => (typeof v === "string" ? v : undefined);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Engine as the Tenant API tells it, the SDK versions it serves (ADR-074 §4), and its changesets with this
|
|
3
|
+
* Tenant's tickets. The SDK carries no changesets (§9): without the Tenant API the list is not known, and every
|
|
4
|
+
* command says so instead of guessing.
|
|
5
|
+
*/
|
|
6
|
+
import type { ChangesetEntry, TenantChangeset } from "../changesets.js";
|
|
7
|
+
import { readChangesets, readEngine } from "../definition-set.js";
|
|
8
|
+
import { SDK_VERSION } from "../version.js";
|
|
9
|
+
import { type Ctx, call, readFailure } from "./context.js";
|
|
10
|
+
import type { Terminal } from "./terminal.js";
|
|
11
|
+
|
|
12
|
+
export type Changeset = ChangesetEntry & Partial<TenantChangeset>;
|
|
13
|
+
|
|
14
|
+
export interface EngineState {
|
|
15
|
+
live: boolean;
|
|
16
|
+
version: string | null;
|
|
17
|
+
released_at: string | null;
|
|
18
|
+
latest: number | null;
|
|
19
|
+
site: { docs: string | null; changesets: string | null };
|
|
20
|
+
sdk: SdkFit;
|
|
21
|
+
reason: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** How the installed SDK stands with the Engine: `minimum` and `built_with` from `GET /tenant/v0/engine`. */
|
|
25
|
+
export interface SdkFit {
|
|
26
|
+
installed: string;
|
|
27
|
+
built_with: string | null;
|
|
28
|
+
minimum: string | null;
|
|
29
|
+
/** `served`: at or above the minimum; `below-minimum`: the Engine no longer serves it without a break; `unknown`: the Engine did not say. */
|
|
30
|
+
fit: "served" | "below-minimum" | "unknown";
|
|
31
|
+
/** Whether the Engine was built with a newer SDK than the one installed. */
|
|
32
|
+
newer_available: boolean;
|
|
33
|
+
/** Whether the installed SDK is newer than the Engine knows: calls it adds may answer 404. */
|
|
34
|
+
ahead_of_engine: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Semantic versions compared by their numbers; a pre-release comes before its release. */
|
|
38
|
+
export function compareVersions(a: string, b: string): number {
|
|
39
|
+
const parse = (v: string) => {
|
|
40
|
+
const [main, pre] = v.split("-", 2);
|
|
41
|
+
return { n: main.split(".").map((x) => Number(x) || 0), pre: pre ?? null };
|
|
42
|
+
};
|
|
43
|
+
const x = parse(a);
|
|
44
|
+
const y = parse(b);
|
|
45
|
+
for (let i = 0; i < 3; i++) if ((x.n[i] ?? 0) !== (y.n[i] ?? 0)) return (x.n[i] ?? 0) - (y.n[i] ?? 0);
|
|
46
|
+
if (x.pre === y.pre) return 0;
|
|
47
|
+
if (x.pre === null) return 1;
|
|
48
|
+
if (y.pre === null) return -1;
|
|
49
|
+
return x.pre < y.pre ? -1 : 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function sdkFit(sdk: { built_with: string; minimum: string } | null | undefined, installed = SDK_VERSION): SdkFit {
|
|
53
|
+
if (!sdk) return { installed, built_with: null, minimum: null, fit: "unknown", newer_available: false, ahead_of_engine: false };
|
|
54
|
+
return {
|
|
55
|
+
installed,
|
|
56
|
+
built_with: sdk.built_with,
|
|
57
|
+
minimum: sdk.minimum,
|
|
58
|
+
fit: compareVersions(installed, sdk.minimum) >= 0 ? "served" : "below-minimum",
|
|
59
|
+
newer_available: compareVersions(installed, sdk.built_with) < 0,
|
|
60
|
+
ahead_of_engine: compareVersions(installed, sdk.built_with) > 0,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function engineInfo(ctx: Ctx): Promise<EngineState> {
|
|
65
|
+
const r = await call(ctx, (fetch, cfg) => readEngine({ ...cfg, fetch }));
|
|
66
|
+
if (r.ok) return { live: true, version: r.value.version, released_at: r.value.released_at, latest: r.value.latest_changeset, site: r.value.site, sdk: sdkFit(r.value.sdk), reason: null };
|
|
67
|
+
return { live: false, version: null, released_at: null, latest: null, site: { docs: null, changesets: null }, sdk: sdkFit(null), reason: readFailure(r) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The changesets after `since`, with the Tenant's ticket and status, when the Tenant API answers. */
|
|
71
|
+
export async function changesetList(ctx: Ctx, since: number): Promise<{ live: boolean; latest: number | null; list: Changeset[]; reason: string | null }> {
|
|
72
|
+
const r = await call(ctx, (fetch, cfg) => readChangesets({ ...cfg, since, fetch }));
|
|
73
|
+
if (r.ok) return { live: true, latest: r.value.latest, list: r.value.changesets, reason: null };
|
|
74
|
+
return { live: false, latest: null, list: [], reason: readFailure(r) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const CLOSED = new Set(["resolved", "canceled"]);
|
|
78
|
+
export const isOpen = (c: Changeset): boolean => Boolean(c.ticket && c.status && !CLOSED.has(c.status));
|
|
79
|
+
|
|
80
|
+
/** Open changesets in the order AGENTS.md asks for: the earliest deadline first, then the oldest. */
|
|
81
|
+
export function byUrgency(a: Changeset, b: Changeset): number {
|
|
82
|
+
if (a.deadline && b.deadline) return a.deadline.localeCompare(b.deadline) || a.number - b.number;
|
|
83
|
+
if (a.deadline) return -1;
|
|
84
|
+
if (b.deadline) return 1;
|
|
85
|
+
return a.number - b.number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How each status looks: its words, its colour, its symbol. */
|
|
89
|
+
export const LOOK: Record<string, [string, "blue" | "cyan" | "yellow" | "magenta" | "green" | "gray", keyof Terminal["sym"]]> = {
|
|
90
|
+
new: ["new", "blue", "info"],
|
|
91
|
+
acknowledged: ["acknowledged", "cyan", "open"],
|
|
92
|
+
in_progress: ["in progress", "yellow", "half"],
|
|
93
|
+
waiting: ["waiting for you", "yellow", "warn"],
|
|
94
|
+
on_hold: ["on hold", "magenta", "pause"],
|
|
95
|
+
resolved: ["resolved", "green", "ok"],
|
|
96
|
+
canceled: ["canceled", "gray", "cross"],
|
|
97
|
+
draft: ["draft", "gray", "open"],
|
|
98
|
+
scheduled: ["scheduled", "cyan", "half"],
|
|
99
|
+
published: ["published", "green", "ok"],
|
|
100
|
+
expired: ["expired", "gray", "dash"],
|
|
101
|
+
withdrawn: ["withdrawn", "gray", "cross"],
|
|
102
|
+
"nothing asked": ["nothing asked", "gray", "dash"],
|
|
103
|
+
"no ticket": ["no ticket", "gray", "dash"],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** A status as text, and painted. */
|
|
107
|
+
export function pill(t: Terminal, key: string): { text: string; painted: string } {
|
|
108
|
+
const look = LOOK[key];
|
|
109
|
+
if (!look) return { text: key || "—", painted: t.paint.gray(key || "—") };
|
|
110
|
+
const text = `${t.sym[look[2]]} ${look[0]}`;
|
|
111
|
+
return { text, painted: t.paint[look[1]](text) };
|
|
112
|
+
}
|