unoverse 0.1.162 → 0.1.164
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/lib/publish.mjs +22 -3
- package/lib/session.mjs +5 -0
- package/package.json +1 -1
- package/vendor/base/items/publish.js +49 -2
package/lib/publish.mjs
CHANGED
|
@@ -81,9 +81,26 @@ export async function publish(args) {
|
|
|
81
81
|
die(`no project "${project}" in ${designRoot}. Found: ${projects.join(", ") || "none"}`);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
// ── which universe: --to overrides, unoverse.yaml remembers,
|
|
84
|
+
// ── which universe: --to overrides, unoverse.yaml remembers, the login answers,
|
|
85
|
+
// and only a machine that knows nothing asks ─────────────────────────────
|
|
85
86
|
const config = await readConfig(ws.root);
|
|
86
87
|
let universe = opt("to") ?? config.universe;
|
|
88
|
+
if (!universe) {
|
|
89
|
+
// THE LOGIN ALREADY NAMED THE UNIVERSE. `unoverse login` takes an address and
|
|
90
|
+
// keeps a session for it, so a machine signed in to exactly one universe has
|
|
91
|
+
// already answered this question — asking it again reads as the CLI forgetting
|
|
92
|
+
// what it was just told (observed live 2026-08-21). One session: use it, say so,
|
|
93
|
+
// and remember it in unoverse.yaml like a typed answer. Several sessions: the
|
|
94
|
+
// choice is real, so the question remains.
|
|
95
|
+
const { sessionOrigins } = await import("./session.mjs");
|
|
96
|
+
const origins = sessionOrigins();
|
|
97
|
+
if (origins.length === 1) {
|
|
98
|
+
universe = origins[0];
|
|
99
|
+
await writeConfig(ws.root, { universe });
|
|
100
|
+
console.log(`
|
|
101
|
+
${c.dim}→ ${universe} (your signed-in universe; saved to unoverse.yaml)${c.off}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
87
104
|
if (!universe) {
|
|
88
105
|
if (!process.stdin.isTTY) die("no universe. Pass --to https://your-universe.example, or commit it in unoverse.yaml");
|
|
89
106
|
const typed = await ask(`\n Where does this workspace deploy to? ${c.dim}(e.g. universe.example.com)${c.off} `);
|
|
@@ -123,7 +140,7 @@ export async function publish(args) {
|
|
|
123
140
|
// ── 3. the plan: what would this publish actually do ────────────────────────
|
|
124
141
|
let plan;
|
|
125
142
|
try {
|
|
126
|
-
plan = await planPublish(items, universe, token);
|
|
143
|
+
plan = await planPublish(items, universe, token, project);
|
|
127
144
|
} catch (e) {
|
|
128
145
|
die(e.message);
|
|
129
146
|
}
|
|
@@ -135,8 +152,10 @@ export async function publish(args) {
|
|
|
135
152
|
for (const i of plan.update) console.log(` ${c.yellow}~${c.off} ${label(i)} ${c.dim}(changed)${c.off}${pending(i)}`);
|
|
136
153
|
if (plan.unchanged.length) console.log(` ${c.dim}= ${plan.unchanged.length} unchanged${c.off}`);
|
|
137
154
|
for (const r of plan.refused) console.log(` ${c.red}✗ ${label(r)}${c.off} ${c.dim}${r.why}${c.off}`);
|
|
155
|
+
// Deploy is a SYNC: what left the workspace leaves the universe, said before it happens.
|
|
156
|
+
for (const r of plan.remove ?? []) console.log(` ${c.red}-${c.off} ${label(r)} ${c.dim}(no longer in this workspace — will be removed)${c.off}`);
|
|
138
157
|
|
|
139
|
-
const toSend = plan.create.length + plan.update.length;
|
|
158
|
+
const toSend = plan.create.length + plan.update.length + (plan.remove?.length ?? 0);
|
|
140
159
|
if (!toSend) {
|
|
141
160
|
console.log(`\n ${c.green}✓${c.off} nothing to do, ${project} is up to date\n`);
|
|
142
161
|
process.exit(plan.refused.length ? 1 : 0);
|
package/lib/session.mjs
CHANGED
|
@@ -38,6 +38,11 @@ export function getSession(origin) {
|
|
|
38
38
|
return load()[origin] ?? null;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Every universe origin this machine holds a session for. */
|
|
42
|
+
export function sessionOrigins() {
|
|
43
|
+
return Object.keys(load());
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
export function saveSession(origin, session) {
|
|
42
47
|
const all = load();
|
|
43
48
|
all[origin] = session;
|
package/package.json
CHANGED
|
@@ -35,8 +35,41 @@ export async function lintForPublish(designRoot, project) {
|
|
|
35
35
|
* owned by another publisher is refused, and finding that out before a publish beats
|
|
36
36
|
* finding it out halfway through one.
|
|
37
37
|
*/
|
|
38
|
-
export async function planPublish(items, universe, token,
|
|
39
|
-
|
|
38
|
+
export async function planPublish(items, universe, token,
|
|
39
|
+
/** The project being deployed; names the org whose universe rows the sync reconciles. */
|
|
40
|
+
project = "", fetchImpl = fetch) {
|
|
41
|
+
const plan = { create: [], update: [], unchanged: [], refused: [], remove: [] };
|
|
42
|
+
/**
|
|
43
|
+
* DEPLOY IS A SYNC (ruled 2026-08-21): what the workspace no longer holds, the
|
|
44
|
+
* universe should not keep serving under this project's name. The universe lists its
|
|
45
|
+
* rows for the project; anything absent from the collected items joins the plan as a
|
|
46
|
+
* removal, shown and confirmed like everything else.
|
|
47
|
+
*
|
|
48
|
+
* ONE GUARD, against the catastrophic misread: a workspace that holds NO items for
|
|
49
|
+
* the project (a fresh clone missing its files, the wrong folder entirely) does not
|
|
50
|
+
* get to empty the universe. Removals are proposed only when the workspace holds at
|
|
51
|
+
* least one item — deleting the last one is done by name, deliberately.
|
|
52
|
+
*/
|
|
53
|
+
if (project && items.length > 0) {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetchImpl(`${universe}/publish`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
58
|
+
body: JSON.stringify({ op: "list", project }),
|
|
59
|
+
});
|
|
60
|
+
if (res.ok) {
|
|
61
|
+
const doc = (await res.json().catch(() => null));
|
|
62
|
+
const have = new Set(items.map((i) => `${i.kind}/${i.name}`));
|
|
63
|
+
for (const r of doc?.items ?? []) {
|
|
64
|
+
if (!have.has(`${r.kind}/${r.name}`))
|
|
65
|
+
plan.remove.push(r);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// An older universe answers this op with an error: no removals proposed, the
|
|
69
|
+
// additive behavior every deploy had before. Never a failure.
|
|
70
|
+
}
|
|
71
|
+
catch { /* unreachable universe fails later, on the real sends */ }
|
|
72
|
+
}
|
|
40
73
|
for (const item of items) {
|
|
41
74
|
// ASK THE GATE, not the item store. `/api/items` is deliberately shut (§9.10), so a
|
|
42
75
|
// publisher cannot read rows directly; and asking the publish route means the plan is
|
|
@@ -88,6 +121,20 @@ export async function sendPublish(plan, universe, token, fetchImpl = fetch) {
|
|
|
88
121
|
else
|
|
89
122
|
failed.push({ ...item, why: body.error ?? `HTTP ${res.status}` });
|
|
90
123
|
}
|
|
124
|
+
// Removals LAST, after the writes landed: a sync that dies mid-run has then added
|
|
125
|
+
// before it has taken away, which is the recoverable order.
|
|
126
|
+
for (const r of plan.remove ?? []) {
|
|
127
|
+
const res = await fetchImpl(`${universe}/publish`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
130
|
+
body: JSON.stringify({ op: "remove", kind: r.kind, name: r.name }),
|
|
131
|
+
});
|
|
132
|
+
const body = (await res.json().catch(() => ({})));
|
|
133
|
+
if (res.ok)
|
|
134
|
+
sent.push({ ...r, mode: "removed" });
|
|
135
|
+
else
|
|
136
|
+
failed.push({ ...r, why: body.error ?? `HTTP ${res.status}` });
|
|
137
|
+
}
|
|
91
138
|
return { sent, failed };
|
|
92
139
|
}
|
|
93
140
|
export { collectProject };
|