unoverse 0.1.154 → 0.1.156
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/bin/unoverse.mjs +49 -2
- package/lib/login.mjs +274 -0
- package/lib/publish.mjs +168 -0
- package/lib/session.mjs +95 -0
- package/lib/workspace.mjs +72 -0
- package/operator/lib/ground.sh +1 -0
- package/package.json +6 -2
- package/vendor/base/items/baseVersion.js +99 -0
- package/vendor/base/items/collect.js +140 -0
- package/vendor/base/items/fingerprint.js +35 -0
- package/vendor/base/items/publish.js +94 -0
- package/vendor/base/lint/design/defs.mjs +46 -0
- package/vendor/base/lint/design/file.mjs +450 -0
- package/vendor/base/lint/design/index.mjs +856 -0
- package/vendor/base/lint/design/tokens.mjs +128 -0
- package/vendor/base/lint/design/vocabulary.mjs +93 -0
- package/vendor/base/lint/design/walk.mjs +285 -0
package/bin/unoverse.mjs
CHANGED
|
@@ -59,8 +59,11 @@ const UNIVERSE = findUniverse();
|
|
|
59
59
|
// and that refresh now belongs to `start --pull`.
|
|
60
60
|
const OPERATOR_COMMANDS = new Set([
|
|
61
61
|
"start", "stop", "check", "logs", "deploy", "destroy", "db-allow",
|
|
62
|
-
// kept working, not advertised
|
|
63
|
-
|
|
62
|
+
// kept working, not advertised. `publish` is NOT here: it has its own case below,
|
|
63
|
+
// because ONE VERB serves two lanes and the folder decides which (LOCAL_STUDIO.md
|
|
64
|
+
// §Publish is terminal-only) — an asset workspace publishes item rows, a universe
|
|
65
|
+
// publishes the platform via the operator.
|
|
66
|
+
"ground", "dev", "build",
|
|
64
67
|
]);
|
|
65
68
|
|
|
66
69
|
const [, , cmd, ...args] = process.argv;
|
|
@@ -93,6 +96,8 @@ const HELP = [
|
|
|
93
96
|
"",
|
|
94
97
|
` ${bold("After that")}`,
|
|
95
98
|
row("studio", "Design components, nodes and agent skills"),
|
|
99
|
+
row("publish", "Ship your work to your universe"),
|
|
100
|
+
row("login", "Sign in to a universe (publish asks by itself when needed)"),
|
|
96
101
|
row("update", "Update this CLI"),
|
|
97
102
|
"",
|
|
98
103
|
].join("\n");
|
|
@@ -201,6 +206,48 @@ switch (cmd) {
|
|
|
201
206
|
process.exit(r.status ?? 0);
|
|
202
207
|
}
|
|
203
208
|
|
|
209
|
+
case "login": {
|
|
210
|
+
// Standalone sign-in. publish calls the same flow lazily, so this exists for
|
|
211
|
+
// "set up my machine" moments and for re-authenticating after a permissions change.
|
|
212
|
+
const { login, normaliseUrl } = await import("../lib/login.mjs");
|
|
213
|
+
let target = args[0];
|
|
214
|
+
if (!target) {
|
|
215
|
+
// No URL typed: the workspace's committed target is the obvious answer.
|
|
216
|
+
const { findWorkspace, readConfig } = await import("../lib/workspace.mjs");
|
|
217
|
+
const ws = findWorkspace();
|
|
218
|
+
if (ws?.kind === "assets") target = (await readConfig(ws.root)).universe;
|
|
219
|
+
}
|
|
220
|
+
if (!target) {
|
|
221
|
+
console.error(`\n Which universe? unoverse login <url>\n ${dim("(or run it from a workspace whose unoverse.yaml names one)")}\n`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
await login(normaliseUrl(target));
|
|
226
|
+
} catch (e) {
|
|
227
|
+
console.error(`\n ✗ ${e.message}\n`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
case "publish": {
|
|
234
|
+
// ONE VERB, the folder decides (LOCAL_STUDIO.md §Publish is terminal-only).
|
|
235
|
+
// Nearest marker up from cwd: a design home → publish this workspace's items;
|
|
236
|
+
// a docker-compose.yml → publish is the operator's (the platform release lane).
|
|
237
|
+
const { findWorkspace } = await import("../lib/workspace.mjs");
|
|
238
|
+
const ws = findWorkspace();
|
|
239
|
+
if (ws?.kind === "assets") {
|
|
240
|
+
const { publish } = await import("../lib/publish.mjs");
|
|
241
|
+
await publish(args);
|
|
242
|
+
} else if (ws?.kind === "universe") {
|
|
243
|
+
operator({ root: ws.root, script: OPERATOR }, ["publish", ...args]);
|
|
244
|
+
} else {
|
|
245
|
+
console.log(`\n Nothing to publish here: no design/ workspace and no universe above ${process.cwd()}.\n Start one with: unoverse create\n`);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
|
|
204
251
|
case "help":
|
|
205
252
|
case "--help":
|
|
206
253
|
case "-h":
|
package/lib/login.mjs
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unoverse login — the Stripe-shaped browser handshake.
|
|
3
|
+
*
|
|
4
|
+
* A UNIVERSE NAMES ITS OWN IDENTITY PROVIDER. Nothing here hardcodes an issuer or a
|
|
5
|
+
* vendor: the CLI asks the universe at /.well-known/unoverse-universe, then asks that
|
|
6
|
+
* issuer's own /.well-known/openid-configuration for its endpoints. Auth0, Cognito,
|
|
7
|
+
* Okta, Entra and Keycloak all answer the second document, which is why the flow is
|
|
8
|
+
* Authorization Code + PKCE with a loopback redirect and NOT the device flow (Cognito
|
|
9
|
+
* has no device flow; every OIDC provider has this).
|
|
10
|
+
*
|
|
11
|
+
* THE CALLBACK PORT IS FIXED (4109) because IdPs match redirect URIs exactly — an
|
|
12
|
+
* ephemeral port could never be registered. 4109 continues the platform's sequence
|
|
13
|
+
* (4105 public listener, 4106 runtime, 4107 unoverse-runtime, 4108 Studio) and is held
|
|
14
|
+
* only for the seconds the browser takes. ground.sh registers it as an allowed callback.
|
|
15
|
+
*
|
|
16
|
+
* This is the ONLY human authentication on the platform (LOCAL_STUDIO.md §Publish is
|
|
17
|
+
* terminal-only). Keys are for CI; humans get a browser and their normal account.
|
|
18
|
+
*/
|
|
19
|
+
import { createServer } from "node:http";
|
|
20
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import { getSession, saveSession, freshToken } from "./session.mjs";
|
|
23
|
+
|
|
24
|
+
export const DISCOVERY_PATH = "/.well-known/unoverse-universe";
|
|
25
|
+
export const CALLBACK_PORT = 4109;
|
|
26
|
+
const CALLBACK_URL = `http://127.0.0.1:${CALLBACK_PORT}/callback`;
|
|
27
|
+
const LOGIN_TIMEOUT_MS = 300_000;
|
|
28
|
+
|
|
29
|
+
/** What a developer typed → an origin. Mirrors studio's universes.ts. */
|
|
30
|
+
export function normaliseUrl(input) {
|
|
31
|
+
const trimmed = (input ?? "").trim();
|
|
32
|
+
if (!trimmed) throw new Error("Enter a universe address");
|
|
33
|
+
const isLocal = /^(https?:\/\/)?(localhost|127\.0\.0\.1)(:|\/|$)/i.test(trimmed);
|
|
34
|
+
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `${isLocal ? "http" : "https"}://${trimmed}`;
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = new URL(withScheme);
|
|
38
|
+
} catch {
|
|
39
|
+
throw new Error(`"${input}" is not a valid address`);
|
|
40
|
+
}
|
|
41
|
+
return parsed.origin;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ask a universe how to log in. Failure is the common case (wrong address, universe
|
|
46
|
+
* down, a plain website), and each gets a message a developer can act on.
|
|
47
|
+
*/
|
|
48
|
+
export async function discover(origin) {
|
|
49
|
+
let response;
|
|
50
|
+
try {
|
|
51
|
+
response = await fetch(`${origin}${DISCOVERY_PATH}`, { headers: { accept: "application/json" } });
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error(`Could not reach ${origin}. Check the address, and that the universe is running.`);
|
|
54
|
+
}
|
|
55
|
+
if (response.status === 404) throw new Error(`Something answered at ${origin}, but it is not a universe.`);
|
|
56
|
+
if (!response.ok) throw new Error(`${origin} answered with an error (${response.status}).`);
|
|
57
|
+
let body;
|
|
58
|
+
try {
|
|
59
|
+
body = await response.json();
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`${origin} answered, but not like a universe. If it is one, it may need restarting.`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof body?.authEnabled !== "boolean") {
|
|
64
|
+
throw new Error(`${origin} answered, but not like a universe. If it is one, it may need restarting.`);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
authEnabled: body.authEnabled,
|
|
68
|
+
issuer: body.issuer ?? null,
|
|
69
|
+
clientId: body.clientId ?? null,
|
|
70
|
+
audience: body.audience ?? null,
|
|
71
|
+
publishPermission: body.publishPermission ?? "marketplace:publish",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The issuer's own endpoint document. Standard OIDC; both Auth0 and Cognito serve it. */
|
|
76
|
+
export async function oidcEndpoints(issuer) {
|
|
77
|
+
const base = issuer.replace(/\/+$/, "");
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await fetch(`${base}/.well-known/openid-configuration`, { headers: { accept: "application/json" } });
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error(`Could not reach the identity provider at ${issuer}.`);
|
|
83
|
+
}
|
|
84
|
+
if (!response.ok) throw new Error(`The identity provider at ${issuer} answered with an error (${response.status}).`);
|
|
85
|
+
const doc = await response.json();
|
|
86
|
+
if (!doc?.authorization_endpoint || !doc?.token_endpoint) {
|
|
87
|
+
throw new Error(`${issuer} does not serve a usable OpenID configuration.`);
|
|
88
|
+
}
|
|
89
|
+
return doc;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
93
|
+
|
|
94
|
+
/** Best effort; the URL is always printed so a developer can click it themselves. */
|
|
95
|
+
function openBrowser(url) {
|
|
96
|
+
const [cmd, args] =
|
|
97
|
+
process.platform === "darwin" ? ["open", [url]]
|
|
98
|
+
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]]
|
|
99
|
+
: ["xdg-open", [url]];
|
|
100
|
+
try {
|
|
101
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
102
|
+
} catch {
|
|
103
|
+
/* the printed URL is the fallback */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The email inside an id_token, display-only. No verification: nothing trusts it. */
|
|
108
|
+
function emailFrom(idToken) {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(Buffer.from(idToken.split(".")[1], "base64url").toString())?.email ?? null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** One loopback round trip: resolves {code} once the browser lands on /callback. */
|
|
117
|
+
function waitForCallback(state) {
|
|
118
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
119
|
+
const server = createServer((req, res) => {
|
|
120
|
+
const url = new URL(req.url, `http://127.0.0.1:${CALLBACK_PORT}`);
|
|
121
|
+
if (url.pathname !== "/callback") {
|
|
122
|
+
res.writeHead(404).end();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const finish = (title, body) => {
|
|
126
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
127
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font-family:system-ui;display:grid;place-items:center;height:100vh;margin:0"><div style="text-align:center"><h1>${title}</h1><p>${body}</p></div>`);
|
|
128
|
+
};
|
|
129
|
+
const err = url.searchParams.get("error");
|
|
130
|
+
if (err) {
|
|
131
|
+
finish("Sign-in failed", url.searchParams.get("error_description") ?? err);
|
|
132
|
+
cleanup();
|
|
133
|
+
rejectPromise(new Error(`The identity provider refused: ${url.searchParams.get("error_description") ?? err}`));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (url.searchParams.get("state") !== state) {
|
|
137
|
+
finish("Sign-in failed", "State mismatch. Close this tab and run the command again.");
|
|
138
|
+
cleanup();
|
|
139
|
+
rejectPromise(new Error("Login state mismatch — the response did not come from this login attempt."));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
finish("You're signed in", "Return to the terminal.");
|
|
143
|
+
cleanup();
|
|
144
|
+
resolvePromise({ code: url.searchParams.get("code") });
|
|
145
|
+
});
|
|
146
|
+
const timer = setTimeout(() => {
|
|
147
|
+
cleanup();
|
|
148
|
+
rejectPromise(new Error("Login timed out after 5 minutes. Run the command again."));
|
|
149
|
+
}, LOGIN_TIMEOUT_MS);
|
|
150
|
+
function cleanup() {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
server.close();
|
|
153
|
+
}
|
|
154
|
+
server.on("error", (e) => {
|
|
155
|
+
cleanup();
|
|
156
|
+
rejectPromise(
|
|
157
|
+
e.code === "EADDRINUSE"
|
|
158
|
+
? new Error(`Port ${CALLBACK_PORT} is in use (another login in progress?). Free it and retry.`)
|
|
159
|
+
: e,
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
server.listen(CALLBACK_PORT, "127.0.0.1");
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Interactive login to one universe. Returns { origin, accessToken, email }.
|
|
168
|
+
* The session is stored for next time; publish calls this only when no session is live.
|
|
169
|
+
*/
|
|
170
|
+
export async function login(universeInput, { log = console.log } = {}) {
|
|
171
|
+
const origin = normaliseUrl(universeInput);
|
|
172
|
+
const config = await discover(origin);
|
|
173
|
+
|
|
174
|
+
if (!config.authEnabled) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`${origin} has authentication switched off, so there is no account to sign in with.\n` +
|
|
177
|
+
` To publish there, set UNOVERSE_TOKEN to a publish key minted on the universe itself.`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (!config.issuer) throw new Error(`${origin} has no identity provider configured.`);
|
|
181
|
+
if (!config.clientId) throw new Error(`${origin} has no client id configured for sign-in.`);
|
|
182
|
+
|
|
183
|
+
const endpoints = await oidcEndpoints(config.issuer);
|
|
184
|
+
|
|
185
|
+
const verifier = b64url(randomBytes(32));
|
|
186
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
187
|
+
const state = b64url(randomBytes(16));
|
|
188
|
+
|
|
189
|
+
// offline_access only where the IdP declares it (Auth0 needs it for a refresh token;
|
|
190
|
+
// Cognito does not know the scope and refuses requests that name it).
|
|
191
|
+
const scopes = ["openid", "profile", "email"];
|
|
192
|
+
if ((endpoints.scopes_supported ?? []).includes("offline_access")) scopes.push("offline_access");
|
|
193
|
+
|
|
194
|
+
const authUrl = new URL(endpoints.authorization_endpoint);
|
|
195
|
+
authUrl.search = new URLSearchParams({
|
|
196
|
+
response_type: "code",
|
|
197
|
+
client_id: config.clientId,
|
|
198
|
+
redirect_uri: CALLBACK_URL,
|
|
199
|
+
scope: scopes.join(" "),
|
|
200
|
+
state,
|
|
201
|
+
code_challenge: challenge,
|
|
202
|
+
code_challenge_method: "S256",
|
|
203
|
+
...(config.audience ? { audience: config.audience } : {}),
|
|
204
|
+
}).toString();
|
|
205
|
+
|
|
206
|
+
const pending = waitForCallback(state);
|
|
207
|
+
log(`\n Opening your browser to sign in to ${origin}`);
|
|
208
|
+
log(` If it does not open: ${authUrl.href}\n`);
|
|
209
|
+
openBrowser(authUrl.href);
|
|
210
|
+
|
|
211
|
+
const { code } = await pending;
|
|
212
|
+
|
|
213
|
+
const exchange = await fetch(endpoints.token_endpoint, {
|
|
214
|
+
method: "POST",
|
|
215
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
216
|
+
body: new URLSearchParams({
|
|
217
|
+
grant_type: "authorization_code",
|
|
218
|
+
client_id: config.clientId,
|
|
219
|
+
code,
|
|
220
|
+
redirect_uri: CALLBACK_URL,
|
|
221
|
+
code_verifier: verifier,
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
if (!exchange.ok) {
|
|
225
|
+
throw new Error(`The identity provider refused the code exchange (${exchange.status}): ${await exchange.text()}`);
|
|
226
|
+
}
|
|
227
|
+
const tokens = await exchange.json();
|
|
228
|
+
const email = tokens.id_token ? emailFrom(tokens.id_token) : null;
|
|
229
|
+
|
|
230
|
+
saveSession(origin, {
|
|
231
|
+
access_token: tokens.access_token,
|
|
232
|
+
refresh_token: tokens.refresh_token,
|
|
233
|
+
expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
|
|
234
|
+
email,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
log(` ✓ Signed in${email ? ` as ${email}` : ""}\n`);
|
|
238
|
+
return { origin, accessToken: tokens.access_token, email };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A token for this universe, however it can be had, in the ruled order:
|
|
243
|
+
* UNOVERSE_TOKEN (CI) → live session (refreshing) → interactive login (TTY only).
|
|
244
|
+
*/
|
|
245
|
+
export async function tokenFor(origin, { interactive = true, log = console.log } = {}) {
|
|
246
|
+
if (process.env.UNOVERSE_TOKEN) return process.env.UNOVERSE_TOKEN;
|
|
247
|
+
|
|
248
|
+
const config = await discover(origin);
|
|
249
|
+
if (config.authEnabled && config.issuer && config.clientId) {
|
|
250
|
+
try {
|
|
251
|
+
const endpoints = await oidcEndpoints(config.issuer);
|
|
252
|
+
const live = await freshToken(origin, { tokenEndpoint: endpoints.token_endpoint, clientId: config.clientId });
|
|
253
|
+
if (live) return live;
|
|
254
|
+
} catch {
|
|
255
|
+
/* IdP unreachable: fall through to the interactive path, which explains itself */
|
|
256
|
+
}
|
|
257
|
+
} else if (!config.authEnabled) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`${origin} has authentication switched off.\n` +
|
|
260
|
+
` To publish there, set UNOVERSE_TOKEN to a publish key minted on the universe itself.`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!interactive || !process.stdin.isTTY) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`No credential for ${origin}. In CI, set UNOVERSE_TOKEN to a publish key.\n` +
|
|
267
|
+
` At a terminal, run: unoverse login ${origin}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
const { accessToken } = await login(origin, { log });
|
|
271
|
+
return accessToken;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export { getSession };
|
package/lib/publish.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unoverse publish — ship an asset workspace's items to its universe.
|
|
3
|
+
*
|
|
4
|
+
* The terminal face of @unoverse-platform/base items/publish. The order is the safety:
|
|
5
|
+
* LINT, then COMPARE (a dry-run per item against the universe), then a plan and a
|
|
6
|
+
* question, then send. Nothing leaves the machine until the rules pass.
|
|
7
|
+
* (LOCAL_STUDIO.md §Publish is terminal-only; the engine lives in base, this file only
|
|
8
|
+
* resolves context, formats and asks.)
|
|
9
|
+
*
|
|
10
|
+
* Base is consumed as BUILT dist, never source: in this monorepo straight from
|
|
11
|
+
* packages/base/dist (the in-repo copy wins, same rule as the vendored operator), and in
|
|
12
|
+
* the published package from the copy vendor-base.mjs froze at pack time.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { join, dirname, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
+
import { createInterface } from "node:readline/promises";
|
|
18
|
+
import { findWorkspace, designHome, readConfig, writeConfig } from "./workspace.mjs";
|
|
19
|
+
import { normaliseUrl, tokenFor } from "./login.mjs";
|
|
20
|
+
|
|
21
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const IN_REPO_BASE = resolve(here, "../../base/dist");
|
|
23
|
+
const VENDORED_BASE = resolve(here, "../vendor/base");
|
|
24
|
+
|
|
25
|
+
async function importBase(subpath) {
|
|
26
|
+
const root = existsSync(IN_REPO_BASE) ? IN_REPO_BASE : VENDORED_BASE;
|
|
27
|
+
const file = join(root, subpath);
|
|
28
|
+
if (!existsSync(file)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
root === IN_REPO_BASE
|
|
31
|
+
? `packages/base is not built (${subpath} missing). Run: npm run build -w packages/base`
|
|
32
|
+
: `This install is missing its vendored base (${subpath}). Reinstall: npm i -g unoverse@latest`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return import(pathToFileURL(file).href);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const c = { dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", bold: "\x1b[1m", off: "\x1b[0m" };
|
|
39
|
+
const die = (msg) => {
|
|
40
|
+
console.error(`\n ${c.red}✗${c.off} ${msg}\n`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const ask = async (question) => {
|
|
45
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
46
|
+
const answer = await rl.question(question);
|
|
47
|
+
rl.close();
|
|
48
|
+
return answer.trim();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `unoverse publish [project] [--to <universe>] [--dry-run] [--yes]`
|
|
53
|
+
* Run from anywhere inside an asset workspace. bin/unoverse.mjs routes here only when
|
|
54
|
+
* the nearest marker up from cwd is a design home.
|
|
55
|
+
*/
|
|
56
|
+
export async function publish(args) {
|
|
57
|
+
const flag = (name) => args.includes(`--${name}`);
|
|
58
|
+
const opt = (name) => {
|
|
59
|
+
const i = args.indexOf(`--${name}`);
|
|
60
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
61
|
+
};
|
|
62
|
+
const positional = args.find((a) => !a.startsWith("--") && a !== opt("to"));
|
|
63
|
+
|
|
64
|
+
const ws = findWorkspace();
|
|
65
|
+
if (ws?.kind !== "assets") die("no design/ folder here or above. Run this inside your workspace.");
|
|
66
|
+
const designRoot = designHome(ws.root);
|
|
67
|
+
|
|
68
|
+
const { collectProject, listProjects } = await importBase("items/collect.js");
|
|
69
|
+
const { lintForPublish, planPublish, sendPublish } = await importBase("items/publish.js");
|
|
70
|
+
|
|
71
|
+
// ── which project ───────────────────────────────────────────────────────────
|
|
72
|
+
const projects = listProjects(designRoot);
|
|
73
|
+
if (!projects.length) die(`nothing to publish: ${designRoot} holds no projects`);
|
|
74
|
+
let project = positional;
|
|
75
|
+
if (!project) {
|
|
76
|
+
if (projects.length > 1) {
|
|
77
|
+
die(`several projects here. Say which:\n\n unoverse publish <project>\n\n Found: ${projects.join(", ")}`);
|
|
78
|
+
}
|
|
79
|
+
project = projects[0];
|
|
80
|
+
} else if (!projects.includes(project)) {
|
|
81
|
+
die(`no project "${project}" in ${designRoot}. Found: ${projects.join(", ") || "none"}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── which universe: --to overrides, unoverse.yaml remembers, first run asks ─
|
|
85
|
+
const config = await readConfig(ws.root);
|
|
86
|
+
let universe = opt("to") ?? config.universe;
|
|
87
|
+
if (!universe) {
|
|
88
|
+
if (!process.stdin.isTTY) die("no universe. Pass --to https://your-universe.example, or commit it in unoverse.yaml");
|
|
89
|
+
const typed = await ask(`\n Where does this workspace publish to? ${c.dim}(e.g. universe.example.com)${c.off} `);
|
|
90
|
+
if (!typed) die("no universe given");
|
|
91
|
+
universe = normaliseUrl(typed);
|
|
92
|
+
await writeConfig(ws.root, { universe });
|
|
93
|
+
console.log(` ${c.green}✓${c.off} saved to unoverse.yaml ${c.dim}(commit it: the whole team publishes there)${c.off}`);
|
|
94
|
+
} else {
|
|
95
|
+
universe = normaliseUrl(universe);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── 1. lint. Nothing is sent if this fails ──────────────────────────────────
|
|
99
|
+
console.log(`\n ${c.bold}publish ${project}${c.off} ${c.dim}→ ${universe}${c.off}\n`);
|
|
100
|
+
process.stdout.write(` ${c.dim}checking…${c.off}`);
|
|
101
|
+
const { problems, errors } = await lintForPublish(designRoot, project);
|
|
102
|
+
process.stdout.write("\r \r");
|
|
103
|
+
if (errors.length) {
|
|
104
|
+
console.error(` ${c.red}✗ ${errors.length} error(s) in ${project}. Nothing was sent.${c.off}\n`);
|
|
105
|
+
for (const p of errors.slice(0, 10)) console.error(` ${p.file}${p.line ? ":" + p.line : ""} ${p.msg}`);
|
|
106
|
+
if (errors.length > 10) console.error(` ${c.dim}…and ${errors.length - 10} more${c.off}`);
|
|
107
|
+
console.error("");
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
const warnings = problems.filter((p) => p.level === "warn").length;
|
|
111
|
+
console.log(` ${c.green}✓${c.off} checks passed${warnings ? ` ${c.dim}(${warnings} warning(s))${c.off}` : ""}`);
|
|
112
|
+
|
|
113
|
+
// ── 2. the credential, only now that there is something worth sending ───────
|
|
114
|
+
const items = collectProject(designRoot, project);
|
|
115
|
+
if (!items.length) die(`nothing to publish in ${project}`);
|
|
116
|
+
let token;
|
|
117
|
+
try {
|
|
118
|
+
token = await tokenFor(universe, { interactive: !flag("yes") });
|
|
119
|
+
} catch (e) {
|
|
120
|
+
die(e.message);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── 3. the plan: what would this publish actually do ────────────────────────
|
|
124
|
+
let plan;
|
|
125
|
+
try {
|
|
126
|
+
plan = await planPublish(items, universe, token);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
die(e.message);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log("");
|
|
132
|
+
const label = (i) => `${i.kind}/${i.name}`;
|
|
133
|
+
const pending = (i) => (i.kind === "node" ? ` ${c.yellow}(lands PENDING review)${c.off}` : "");
|
|
134
|
+
for (const i of plan.create) console.log(` ${c.green}+${c.off} ${label(i)} ${c.dim}(new)${c.off}${pending(i)}`);
|
|
135
|
+
for (const i of plan.update) console.log(` ${c.yellow}~${c.off} ${label(i)} ${c.dim}(changed)${c.off}${pending(i)}`);
|
|
136
|
+
if (plan.unchanged.length) console.log(` ${c.dim}= ${plan.unchanged.length} unchanged${c.off}`);
|
|
137
|
+
for (const r of plan.refused) console.log(` ${c.red}✗ ${label(r)}${c.off} ${c.dim}${r.why}${c.off}`);
|
|
138
|
+
|
|
139
|
+
const toSend = plan.create.length + plan.update.length;
|
|
140
|
+
if (!toSend) {
|
|
141
|
+
console.log(`\n ${c.green}✓${c.off} nothing to do, ${project} is up to date\n`);
|
|
142
|
+
process.exit(plan.refused.length ? 1 : 0);
|
|
143
|
+
}
|
|
144
|
+
if (flag("dry-run")) {
|
|
145
|
+
console.log(`\n ${c.dim}dry run: ${toSend} item(s) would be sent${c.off}\n`);
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── 4. confirm, unless told not to ──────────────────────────────────────────
|
|
150
|
+
if (!flag("yes")) {
|
|
151
|
+
const answer = await ask(`\n ${toSend} item(s) → ${universe}. Publish? [y/N] `);
|
|
152
|
+
if (!/^y(es)?$/i.test(answer)) {
|
|
153
|
+
console.log(` ${c.dim}cancelled${c.off}\n`);
|
|
154
|
+
process.exit(0);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── 5. send ─────────────────────────────────────────────────────────────────
|
|
159
|
+
const { sent, failed } = await sendPublish(plan, universe, token);
|
|
160
|
+
console.log("");
|
|
161
|
+
for (const s of sent) console.log(` ${c.green}✓${c.off} ${s.mode ?? "sent"} ${c.dim}${s.kind}/${s.name}${c.off}`);
|
|
162
|
+
for (const f of failed) console.log(` ${c.red}✗${c.off} ${f.kind}/${f.name} ${f.why}`);
|
|
163
|
+
console.log(
|
|
164
|
+
`\n ${failed.length ? c.red + "✗" : c.green + "✓"}${c.off} ${sent.length} published` +
|
|
165
|
+
(failed.length ? `, ${failed.length} failed` : "") + "\n",
|
|
166
|
+
);
|
|
167
|
+
process.exit(failed.length ? 1 : 0);
|
|
168
|
+
}
|
package/lib/session.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-machine sessions: ~/.unoverse/sessions.json, keyed by universe origin.
|
|
3
|
+
*
|
|
4
|
+
* THE REPO SAYS WHERE, THE MACHINE PROVES WHO. unoverse.yaml (committed) names the
|
|
5
|
+
* universe; this file (0600, home directory, never inside a project) holds the tokens
|
|
6
|
+
* that prove the developer to it. Same split as .git/config vs ~/.ssh. Nothing under a
|
|
7
|
+
* project folder ever holds a credential, so nothing can be committed or swept into a
|
|
8
|
+
* deploy by accident.
|
|
9
|
+
*
|
|
10
|
+
* A session is whatever the token endpoint returned: access_token, refresh_token when
|
|
11
|
+
* the IdP granted one, expiry, and the email shown in prompts. Refresh happens here so
|
|
12
|
+
* callers only ever ask one question: "a token for this universe, or null".
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const DIR = join(homedir(), ".unoverse");
|
|
19
|
+
const FILE = join(DIR, "sessions.json");
|
|
20
|
+
|
|
21
|
+
function load() {
|
|
22
|
+
if (!existsSync(FILE)) return {};
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(readFileSync(FILE, "utf8"));
|
|
25
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
26
|
+
} catch {
|
|
27
|
+
return {}; // corrupt store: re-login beats a crash
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function save(all) {
|
|
32
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
33
|
+
writeFileSync(FILE, JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
34
|
+
chmodSync(FILE, 0o600); // writeFileSync mode is ignored when the file already exists
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getSession(origin) {
|
|
38
|
+
return load()[origin] ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function saveSession(origin, session) {
|
|
42
|
+
const all = load();
|
|
43
|
+
all[origin] = session;
|
|
44
|
+
save(all);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function clearSession(origin) {
|
|
48
|
+
const all = load();
|
|
49
|
+
delete all[origin];
|
|
50
|
+
save(all);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Seconds of slack so a token never expires mid-publish. */
|
|
54
|
+
const EXPIRY_SLACK_MS = 60_000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A live access token for this universe, refreshing if the IdP granted a refresh token,
|
|
58
|
+
* or null when the developer has to log in again. `tokenEndpoint`/`clientId` come from
|
|
59
|
+
* the universe's own discovery (login.mjs) — nothing is hardcoded.
|
|
60
|
+
*/
|
|
61
|
+
export async function freshToken(origin, { tokenEndpoint, clientId } = {}) {
|
|
62
|
+
const session = getSession(origin);
|
|
63
|
+
if (!session?.access_token) return null;
|
|
64
|
+
if (session.expires_at && Date.now() < session.expires_at - EXPIRY_SLACK_MS) {
|
|
65
|
+
return session.access_token;
|
|
66
|
+
}
|
|
67
|
+
if (!session.refresh_token || !tokenEndpoint || !clientId) return null;
|
|
68
|
+
|
|
69
|
+
let response;
|
|
70
|
+
try {
|
|
71
|
+
response = await fetch(tokenEndpoint, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
74
|
+
body: new URLSearchParams({
|
|
75
|
+
grant_type: "refresh_token",
|
|
76
|
+
client_id: clientId,
|
|
77
|
+
refresh_token: session.refresh_token,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
return null; // offline: treat as expired, the caller decides what to say
|
|
82
|
+
}
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
clearSession(origin); // a refused refresh token is dead; keeping it re-fails forever
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const body = await response.json();
|
|
88
|
+
saveSession(origin, {
|
|
89
|
+
...session,
|
|
90
|
+
access_token: body.access_token,
|
|
91
|
+
refresh_token: body.refresh_token ?? session.refresh_token,
|
|
92
|
+
expires_at: body.expires_in ? Date.now() + body.expires_in * 1000 : session.expires_at,
|
|
93
|
+
});
|
|
94
|
+
return body.access_token;
|
|
95
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The asset workspace: where a developer's design/, prompts/ and nodes/ live.
|
|
3
|
+
*
|
|
4
|
+
* Detection mirrors packages/studio/local/{design-home.mjs,catalog.ts} — a workspace is a
|
|
5
|
+
* folder holding `design/` (or legacy `rx/`, migrated on adoption). A LOCAL copy, same
|
|
6
|
+
* reason studio keeps one: this package is plain node and cannot import base's TypeScript.
|
|
7
|
+
* Keep the three in step.
|
|
8
|
+
*
|
|
9
|
+
* `unoverse.yaml` at the workspace root is the COMMITTED project config — today one key,
|
|
10
|
+
* `universe:`, the publish target. It is config, not a secret: the whole team and CI
|
|
11
|
+
* publish to the same place, which is exactly why it belongs in the repo. Credentials
|
|
12
|
+
* never live here (they are per machine, in ~/.unoverse — see session.mjs).
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, renameSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
export function hasDesignHome(root) {
|
|
18
|
+
return existsSync(join(root, "design")) || existsSync(join(root, "rx"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function designHome(root) {
|
|
22
|
+
const design = join(root, "design");
|
|
23
|
+
const legacy = join(root, "rx");
|
|
24
|
+
if (!existsSync(design) && existsSync(legacy)) {
|
|
25
|
+
try {
|
|
26
|
+
renameSync(legacy, design);
|
|
27
|
+
console.warn(` migrated ${legacy} → ${design} (rx/ is now design/)`);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.warn(` could not rename ${legacy} → ${design} (${e.message}). Rename it by hand; the platform reads design/ only.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return design;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Walk up from `from` for the nearest marker. The NEAREST one decides what `publish`
|
|
37
|
+
* means (LOCAL_STUDIO.md §Publish is terminal-only: one verb, the folder decides):
|
|
38
|
+
* a design home → this is an asset workspace, publish means item rows; a
|
|
39
|
+
* docker-compose.yml first → this is a universe, publish stays the operator's.
|
|
40
|
+
* Checked in that order at each level, so a universe folder that also holds a design
|
|
41
|
+
* tree (the platform monorepo's apps/unoverse) is an asset workspace when you stand in
|
|
42
|
+
* it, and a universe when you stand at its compose root.
|
|
43
|
+
*/
|
|
44
|
+
export function findWorkspace(from = process.cwd()) {
|
|
45
|
+
for (let dir = resolve(from); ; ) {
|
|
46
|
+
if (hasDesignHome(dir)) return { kind: "assets", root: dir };
|
|
47
|
+
if (existsSync(join(dir, "docker-compose.yml"))) return { kind: "universe", root: dir };
|
|
48
|
+
const parent = resolve(dir, "..");
|
|
49
|
+
if (parent === dir) return null;
|
|
50
|
+
dir = parent;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const CONFIG_FILE = "unoverse.yaml";
|
|
55
|
+
|
|
56
|
+
/** Read unoverse.yaml. Absent file is `{}`, not an error — first publish writes it. */
|
|
57
|
+
export async function readConfig(root) {
|
|
58
|
+
const path = join(root, CONFIG_FILE);
|
|
59
|
+
if (!existsSync(path)) return {};
|
|
60
|
+
const { parse } = await import("yaml");
|
|
61
|
+
const parsed = parse(readFileSync(path, "utf8"));
|
|
62
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Merge values into unoverse.yaml, preserving whatever else the team keeps there. */
|
|
66
|
+
export async function writeConfig(root, values) {
|
|
67
|
+
const path = join(root, CONFIG_FILE);
|
|
68
|
+
const { stringify } = await import("yaml");
|
|
69
|
+
const next = { ...(await readConfig(root)), ...values };
|
|
70
|
+
writeFileSync(path, stringify(next));
|
|
71
|
+
return next;
|
|
72
|
+
}
|