uniweb 0.59.2 → 0.60.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/package.json +5 -5
- package/partials/agents.md +6 -4
- package/src/backend/asset-download.js +6 -4
- package/src/backend/client.js +38 -91
- package/src/backend/foundation-bring-along.js +12 -12
- package/src/backend/service-request.js +12 -8
- package/src/backend/site-sync.js +243 -164
- package/src/commands/clone.js +42 -39
- package/src/commands/deploy.js +45 -27
- package/src/commands/forget.js +197 -0
- package/src/commands/publish.js +102 -73
- package/src/commands/pull.js +58 -46
- package/src/commands/push.js +69 -53
- package/src/commands/refresh.js +22 -29
- package/src/commands/register.js +10 -14
- package/src/commands/status.js +13 -11
- package/src/commands/sync.js +0 -1
- package/src/framework-index.json +3 -3
- package/src/index.js +100 -75
- package/src/utils/args.js +3 -4
- package/src/utils/asset-upload.js +18 -5
- package/src/utils/config.js +79 -42
- package/src/utils/flag-guard.js +70 -27
- package/src/utils/interactive.js +38 -0
- package/src/utils/records-guard.js +3 -2
- package/src/utils/registry-auth.js +150 -28
- package/src/utils/scaffold.js +11 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +4 -3
- package/src/utils/site-identity.js +89 -299
package/src/utils/flag-guard.js
CHANGED
|
@@ -6,16 +6,17 @@
|
|
|
6
6
|
* does not recognize does not fail: it *disappears*, and the thing it was meant to
|
|
7
7
|
* change silently keeps its default.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Tolerable for a cosmetic flag; dangerous for any flag that aims the command or picks
|
|
10
|
+
* its identity. This turns that class into one sentence.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
12
|
+
* ⛔ **`--backend` and `--token` are NOT flags of these verbs** *(2026-09-21)*. Every
|
|
13
|
+
* backend verb goes to the backend you are logged in to, with that login's session:
|
|
14
|
+
* switching and signing in are `uniweb login` (`--backend`, `--token`), and a script
|
|
15
|
+
* aims one process with UNIWEB_REGISTER_URL + UNIWEB_TOKEN. Both flags predate
|
|
16
|
+
* per-backend sessions. Passed now, each is an unknown flag — and this guard is what
|
|
17
|
+
* makes that a loud error, with a pointer to the login, instead of a command that
|
|
18
|
+
* quietly runs against wherever and as whoever you happen to be. `--backend` stays on
|
|
19
|
+
* `forget`, where it SELECTS which backend's records to remove.
|
|
19
20
|
*
|
|
20
21
|
* ⚠️ A wrong rejection is worse than a missed one — it breaks an invocation that
|
|
21
22
|
* works — so the per-command lists must be complete, INCLUDING flags read by
|
|
@@ -38,8 +39,8 @@ const GLOBAL = ['--non-interactive', '--help', '-h']
|
|
|
38
39
|
const AUTH = ['--browser', '--password', '--token-paste']
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
* Flags a verb inherits by importing `commands/deploy.js` for `resolveSiteDir
|
|
42
|
-
*
|
|
42
|
+
* Flags a verb inherits by importing `commands/deploy.js` for `resolveSiteDir`.
|
|
43
|
+
* Inert on these verbs, and listed rather than filtered:
|
|
43
44
|
* accepting a flag that does nothing is exactly the pre-guard behaviour, while
|
|
44
45
|
* rejecting one that works is a broken command. The guard's job is catching
|
|
45
46
|
* `--backed`, not policing inert-but-valid spellings.
|
|
@@ -54,8 +55,8 @@ const VIA_DEPLOY = ['--target', '--host', '--no-save']
|
|
|
54
55
|
*/
|
|
55
56
|
const VERBS = {
|
|
56
57
|
push: [
|
|
57
|
-
'--all', '--as-org', '--org', '--
|
|
58
|
-
'--foundation', '--output', '-o', '--personal',
|
|
58
|
+
'--all', '--as-org', '--org', '--dry-run', '--force',
|
|
59
|
+
'--foundation', '--output', '-o', '--personal',
|
|
59
60
|
// read in utils/conformance.js and backend/site-sync.js respectively —
|
|
60
61
|
// neither appears in push.js
|
|
61
62
|
'--no-validate', '--yes',
|
|
@@ -68,29 +69,36 @@ const VERBS = {
|
|
|
68
69
|
'--no-release', ...VIA_DEPLOY
|
|
69
70
|
],
|
|
70
71
|
publish: [
|
|
71
|
-
'--as-org', '--org', '--
|
|
72
|
-
'--personal',
|
|
72
|
+
'--as-org', '--org', '--dry-run', '--force', '--foundation',
|
|
73
|
+
'--personal',
|
|
73
74
|
// read in utils/conformance.js, backend/site-sync.js, and
|
|
74
75
|
// backend/foundation-bring-along.js — none appear in publish.js
|
|
75
76
|
'--no-validate', '--yes', '--no-verify', '--no-release', ...VIA_DEPLOY
|
|
76
77
|
],
|
|
77
78
|
pull: [
|
|
78
|
-
'--
|
|
79
|
+
'--content-only', '--dry-run', '--force', '--merge',
|
|
79
80
|
'--no-assets',
|
|
80
|
-
'--no-records', '--no-delete', '--no-prune',
|
|
81
|
+
'--no-records', '--no-delete', '--no-prune',
|
|
81
82
|
// via backend/site-sync.js (the owner resolver) and utils/conformance.js
|
|
82
83
|
'--yes', '--org', '--as-org', '--no-validate', ...VIA_DEPLOY
|
|
83
84
|
],
|
|
84
85
|
clone: [
|
|
85
|
-
'--
|
|
86
|
-
'--project', '--
|
|
86
|
+
'--content-only', '--no-assets', '--no-records', '--path',
|
|
87
|
+
'--project', '--org', '--as-org'
|
|
87
88
|
],
|
|
88
89
|
register: [
|
|
89
|
-
'--
|
|
90
|
-
'--schema-only', '--scope', '--
|
|
90
|
+
'--dry-run', '--json', '--output', '-o',
|
|
91
|
+
'--schema-only', '--scope', '--org', '--as-org'
|
|
91
92
|
],
|
|
93
|
+
/**
|
|
94
|
+
* `forget` = remove one backend's records (`--backend <url>`), or everything a
|
|
95
|
+
* copied project inherited (`--all`). One of the two is required — the verb refuses
|
|
96
|
+
* without it — and they exclude each other. `--non-interactive` reaches it through
|
|
97
|
+
* resolveSiteDir in a workspace of several sites.
|
|
98
|
+
*/
|
|
99
|
+
forget: ['--backend', '--all'],
|
|
92
100
|
status: [
|
|
93
|
-
'--
|
|
101
|
+
'--json', '--remote', '--dry-run',
|
|
94
102
|
'--force', '--no-verify', '--no-validate', '--yes', '--org', '--as-org',
|
|
95
103
|
// inert here, reachable through the bring-along module status imports for
|
|
96
104
|
// `resolveLocalFoundation` — listed per the over-approximation note above
|
|
@@ -99,10 +107,9 @@ const VERBS = {
|
|
|
99
107
|
/**
|
|
100
108
|
* `refresh` = `git pull`, then a DELEGATED `pull --merge`.
|
|
101
109
|
*
|
|
102
|
-
* It forwards
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* `--no-prune`, `--content-only`, …) are NOT reachable from a `refresh` argv and
|
|
110
|
+
* It forwards nothing of its own argv to that pull — it builds `['--merge']`
|
|
111
|
+
* itself. So pull's own flags (`--merge`, `--force`, `--no-delete`, `--no-prune`,
|
|
112
|
+
* `--content-only`, …) are NOT reachable from a `refresh` argv and
|
|
106
113
|
* are deliberately absent here. ⚠️ `--force` especially: `refresh` is read-only by
|
|
107
114
|
* design, and forwarding it would ask pull to DISCARD local work.
|
|
108
115
|
*
|
|
@@ -111,7 +118,7 @@ const VERBS = {
|
|
|
111
118
|
* the VIA_DEPLOY note above, and required by `flag-guard-coverage.test.js`.
|
|
112
119
|
*/
|
|
113
120
|
refresh: [
|
|
114
|
-
'--
|
|
121
|
+
'--no-backend', '--no-git',
|
|
115
122
|
'--as-org', '--org', '--dry-run', '--no-validate', '--yes', ...VIA_DEPLOY
|
|
116
123
|
]
|
|
117
124
|
}
|
|
@@ -143,6 +150,18 @@ VERBS.sync = [...new Set([...VERBS.refresh, ...VERBS.push])]
|
|
|
143
150
|
* flag accepted here but inert costs nothing (it was ignored before the guard
|
|
144
151
|
* existed), while one rejected here breaks a working command.
|
|
145
152
|
*/
|
|
153
|
+
/**
|
|
154
|
+
* Read in the verbs' import graphs, and deliberately NOT accepted by them.
|
|
155
|
+
*
|
|
156
|
+
* `--token` is read by `runRegistryLogin` (utils/registry-auth.js) for
|
|
157
|
+
* `uniweb login --token <bearer>`, which seeds and STORES a session. The verbs reach
|
|
158
|
+
* that function through `ensureRegistryAuth`, which strips `--token` from their argv
|
|
159
|
+
* first — so a static walk sees the read, and no verb can honour it.
|
|
160
|
+
* `flag-guard-coverage.test.js` subtracts this list; add to it only with the same
|
|
161
|
+
* proof: a read no verb's argv can reach.
|
|
162
|
+
*/
|
|
163
|
+
export const LOGIN_ONLY = ['--token']
|
|
164
|
+
|
|
146
165
|
export const VERB_FLAGS = Object.fromEntries(
|
|
147
166
|
Object.entries(VERBS).map(([verb, flags]) => [
|
|
148
167
|
verb,
|
|
@@ -170,6 +189,30 @@ export function checkFlags(verb, args = []) {
|
|
|
170
189
|
if (!unknown.length) return null
|
|
171
190
|
|
|
172
191
|
const flag = unknown[0]
|
|
192
|
+
// ⭐ `--backend` and `--token` are not typos on these verbs — they are RETIRED
|
|
193
|
+
// (2026-09-21), and the useful answer is what replaced them, not "run --help".
|
|
194
|
+
if (flag === '--backend') {
|
|
195
|
+
return {
|
|
196
|
+
flag,
|
|
197
|
+
suggestion: null,
|
|
198
|
+
message: [
|
|
199
|
+
`\`uniweb ${verb}\` has no \`--backend\`: it goes to the backend you are logged in to.`,
|
|
200
|
+
' Switch with: uniweb login --backend <url>',
|
|
201
|
+
' (A script can aim one process with UNIWEB_REGISTER_URL instead.)'
|
|
202
|
+
].join('\n')
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (flag === '--token') {
|
|
206
|
+
return {
|
|
207
|
+
flag,
|
|
208
|
+
suggestion: null,
|
|
209
|
+
message: [
|
|
210
|
+
`\`uniweb ${verb}\` has no \`--token\`: it uses the session of the backend you are logged in to.`,
|
|
211
|
+
' Sign in with a token: uniweb login --backend <url> --token <bearer>',
|
|
212
|
+
' (A script can authenticate one process with UNIWEB_TOKEN instead.)'
|
|
213
|
+
].join('\n')
|
|
214
|
+
}
|
|
215
|
+
}
|
|
173
216
|
const suggestion = didYouMean(flag, all)
|
|
174
217
|
const lines = [`Unknown flag \`${flag}\` for \`uniweb ${verb}\`.`]
|
|
175
218
|
if (suggestion) lines.push(` Did you mean \`${suggestion}\`?`)
|
package/src/utils/interactive.js
CHANGED
|
@@ -45,6 +45,44 @@ export async function confirm(question, defaultYes = false) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Ask the user to pick one of a short list, returning `fallback` on an empty answer
|
|
50
|
+
* or anything unparseable.
|
|
51
|
+
*
|
|
52
|
+
* ⛔ **Prompts on STDERR**, like everything else a human reads here — `stdout` is the
|
|
53
|
+
* CLI's data channel and a prompt written there corrupts any caller piping it.
|
|
54
|
+
*
|
|
55
|
+
* ⛔ Callers must decide NOT to ask before calling (see `confirm`): this reads stdin,
|
|
56
|
+
* and a prompt in a non-interactive run hangs a pipeline.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} question
|
|
59
|
+
* @param {string[]} options - shown in order, numbered from 1
|
|
60
|
+
* @param {string} fallback - returned for an empty or unparseable answer
|
|
61
|
+
* @returns {Promise<string>}
|
|
62
|
+
*/
|
|
63
|
+
export async function pickOne(question, options, fallback) {
|
|
64
|
+
if (!Array.isArray(options) || options.length === 0) return fallback
|
|
65
|
+
if (options.length === 1) return options[0]
|
|
66
|
+
const { createInterface } = await import('node:readline/promises')
|
|
67
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
68
|
+
try {
|
|
69
|
+
process.stderr.write(`${question}\n`)
|
|
70
|
+
options.forEach((o, i) => {
|
|
71
|
+
const mark = o === fallback ? ' \x1b[2m(default)\x1b[0m' : ''
|
|
72
|
+
process.stderr.write(` \x1b[1m${i + 1}\x1b[0m) ${o}${mark}\n`)
|
|
73
|
+
})
|
|
74
|
+
const a = (await rl.question(`Choose [1-${options.length}]: `)).trim()
|
|
75
|
+
if (!a) return fallback
|
|
76
|
+
const n = Number.parseInt(a, 10)
|
|
77
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1]
|
|
78
|
+
// A pasted origin is a reasonable answer to a list of origins.
|
|
79
|
+
if (options.includes(a)) return a
|
|
80
|
+
return fallback
|
|
81
|
+
} finally {
|
|
82
|
+
rl.close()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
/**
|
|
49
87
|
* Get the CLI invocation prefix to use in suggested commands.
|
|
50
88
|
* Mirrors however the user actually ran the CLI.
|
|
@@ -40,17 +40,18 @@ export function countPlacedRecords(pathToUuid) {
|
|
|
40
40
|
*
|
|
41
41
|
* @param {object} params
|
|
42
42
|
* @param {string} params.siteDir
|
|
43
|
+
* @param {string} params.backend - whose placements to count; they are minted per backend
|
|
43
44
|
* @param {string[]} params.args - the verb's argv, for --yes / non-interactive
|
|
44
45
|
* @param {(m: string) => void} params.warn - the CALLER's reporter. Each verb owns
|
|
45
46
|
* its own output style; a second copy here would drift from all of them.
|
|
46
47
|
* @param {(m: string) => void} params.note
|
|
47
48
|
* @returns {Promise<{ ok: boolean, count: number }>} `ok: false` means abort
|
|
48
49
|
*/
|
|
49
|
-
export async function guardEmptyRecords({ siteDir, args = [], warn, note }) {
|
|
50
|
+
export async function guardEmptyRecords({ siteDir, backend, args = [], warn, note }) {
|
|
50
51
|
const cfg = await readRecordsConfig(siteDir)
|
|
51
52
|
if (cfg.state !== FOLDER_EMPTY) return { ok: true, count: 0 }
|
|
52
53
|
|
|
53
|
-
const count = countPlacedRecords(readFolderItemUuids(siteDir))
|
|
54
|
+
const count = countPlacedRecords(readFolderItemUuids(siteDir, backend))
|
|
54
55
|
// Nothing banked ⇒ nothing this push can remove. A first push of an empty
|
|
55
56
|
// folder is a legitimate (if odd) thing to do, and asking about it would train
|
|
56
57
|
// people to type y.
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
* - stored in a register-scoped slot (~/.uniweb/registry-auth.json) so it can
|
|
33
33
|
* never clobber the legacy token publish/deploy rely on.
|
|
34
34
|
*
|
|
35
|
-
* Token resolution for
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Token resolution for the backend commands, which take no `--token` flag:
|
|
36
|
+
* UNIWEB_TOKEN env > stored session (unexpired) > UNIWEB_USERNAME/UNIWEB_PASSWORD
|
|
37
|
+
* env (non-interactive) > interactive prompt.
|
|
38
38
|
*
|
|
39
39
|
* Login response shape (agreed with backend, 2026-05-26):
|
|
40
40
|
* { token, expires_at, account: { uuid, username, handle } }
|
|
@@ -48,6 +48,9 @@ import { homedir } from 'node:os'
|
|
|
48
48
|
import { createServer } from 'node:http'
|
|
49
49
|
import { randomBytes } from 'node:crypto'
|
|
50
50
|
|
|
51
|
+
import { DEFAULT_BACKEND_ORIGIN } from './config.js'
|
|
52
|
+
import { normalizeSessionFile, sessionFilePath, loggedInOriginOf } from './session-file.js'
|
|
53
|
+
|
|
51
54
|
const LOGIN_PATH = '/dev/auth/login'
|
|
52
55
|
|
|
53
56
|
/** The shared ~/.uniweb credential directory. */
|
|
@@ -58,7 +61,10 @@ export function getAuthDir() {
|
|
|
58
61
|
/** Normalize a backend URL to a bare origin (for stamping on the session). */
|
|
59
62
|
function normOrigin(u) {
|
|
60
63
|
try {
|
|
61
|
-
|
|
64
|
+
// http(s) only — `localhost:8080` parses as a scheme with the origin "null", and a
|
|
65
|
+
// session keyed "null" would answer for nothing.
|
|
66
|
+
const parsed = new URL(u)
|
|
67
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.origin : u
|
|
62
68
|
} catch {
|
|
63
69
|
return u
|
|
64
70
|
}
|
|
@@ -77,39 +83,102 @@ export function isExpired(auth) {
|
|
|
77
83
|
* @returns {string}
|
|
78
84
|
*/
|
|
79
85
|
export function getRegistryAuthPath() {
|
|
80
|
-
return
|
|
86
|
+
return sessionFilePath()
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
90
|
+
* ONE SESSION, FOR ONE BACKEND — the on-disk shape.
|
|
91
|
+
*
|
|
92
|
+
* { version: 2, current: "https://uniweb.app",
|
|
93
|
+
* sessions: { "https://uniweb.app": { token, expiresAt, … } } }
|
|
94
|
+
*
|
|
95
|
+
* ⭐ **At most one session exists** *[Diego, 2026-09-21: "`uniweb login` log the user out
|
|
96
|
+
* of the existing session, if any, before logging in to the new backend … `uniweb logout`
|
|
97
|
+
* always logs you out of the one backend you may be logged in"]*. A login REPLACES the
|
|
98
|
+
* file — once it succeeds, so a cancelled or failed login leaves you where you were —
|
|
99
|
+
* and a logout deletes it. So "the backend you are logged in to" is exactly one thing
|
|
100
|
+
* or nothing, and logout needs no selector.
|
|
101
|
+
*
|
|
102
|
+
* ⚖️ The v2 map is kept, holding one entry: the file then has one reader
|
|
103
|
+
* (utils/session-file.js), and it still understands what came before — a v1 flat
|
|
104
|
+
* record, and a v2 file holding SEVERAL sessions (the per-backend store of 2026-09-20,
|
|
105
|
+
* which kept every backend's session and marked the last login `current`). The next
|
|
106
|
+
* login or logout rewrites either into the single-session shape.
|
|
107
|
+
*
|
|
108
|
+
* ⛔ Every session is still checked against the backend it is for (`readRegistryAuth`
|
|
109
|
+
* takes the origin): a token issued by A is never handed to a request against B. That
|
|
110
|
+
* was the correctness bug of the original flat record, and it stays fixed.
|
|
111
|
+
*
|
|
112
|
+
* The shape is read in ONE place, `utils/session-file.js`.
|
|
87
113
|
*/
|
|
88
|
-
|
|
114
|
+
|
|
115
|
+
/** The whole file, normalized to v2 shape. Never throws. */
|
|
116
|
+
async function readAuthFile() {
|
|
89
117
|
const path = getRegistryAuthPath()
|
|
90
|
-
if (!existsSync(path)) return null
|
|
118
|
+
if (!existsSync(path)) return normalizeSessionFile(null, DEFAULT_BACKEND_ORIGIN)
|
|
91
119
|
try {
|
|
92
|
-
return JSON.parse(await readFile(path, 'utf8'))
|
|
120
|
+
return normalizeSessionFile(JSON.parse(await readFile(path, 'utf8')), DEFAULT_BACKEND_ORIGIN)
|
|
93
121
|
} catch {
|
|
94
|
-
return null
|
|
122
|
+
return normalizeSessionFile(null, DEFAULT_BACKEND_ORIGIN)
|
|
95
123
|
}
|
|
96
124
|
}
|
|
97
125
|
|
|
126
|
+
async function writeAuthFile({ version, current, sessions }) {
|
|
127
|
+
await mkdir(getAuthDir(), { recursive: true })
|
|
128
|
+
await writeFile(
|
|
129
|
+
getRegistryAuthPath(),
|
|
130
|
+
JSON.stringify({ version, ...(current ? { current } : {}), sessions }, null, 2)
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The stored session FOR ONE BACKEND, or null. No JWT backfill — the token is
|
|
136
|
+
* opaque, so there are no claims to decode (unlike legacy readAuth()).
|
|
137
|
+
*
|
|
138
|
+
* ⛔ `origin` is required in spirit: calling this without one used to mean "the
|
|
139
|
+
* session", which is the assumption this file exists to remove. It falls back to the
|
|
140
|
+
* default backend rather than throwing, because every caller now passes one and a
|
|
141
|
+
* throw here would turn a stale call site into a crash instead of a wrong answer.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} [origin] - the backend whose session is wanted
|
|
144
|
+
* @returns {Promise<{ token: string, expiresAt?: string, accountId?: number, sessionId?: number, username?: string, handle?: string, uuid?: string, origin: string } | null>}
|
|
145
|
+
*/
|
|
146
|
+
export async function readRegistryAuth(origin) {
|
|
147
|
+
const key = normOrigin(origin || DEFAULT_BACKEND_ORIGIN)
|
|
148
|
+
const { sessions } = await readAuthFile()
|
|
149
|
+
const found = sessions[key]
|
|
150
|
+
return found && typeof found.token === 'string' ? { ...found, origin: key } : null
|
|
151
|
+
}
|
|
152
|
+
|
|
98
153
|
/**
|
|
99
|
-
* Persist the
|
|
100
|
-
*
|
|
154
|
+
* Persist THE session — the backend the user is now logged in to, replacing whatever
|
|
155
|
+
* the file held. The record's own `origin` is the key, so every login path keeps
|
|
156
|
+
* stamping it exactly as before.
|
|
157
|
+
* @param {Object} record - must carry `origin` and `token`
|
|
101
158
|
*/
|
|
102
159
|
export async function writeRegistryAuth(record) {
|
|
103
|
-
|
|
104
|
-
|
|
160
|
+
const key = normOrigin(record?.origin || DEFAULT_BACKEND_ORIGIN)
|
|
161
|
+
const { origin: _drop, ...rest } = record || {}
|
|
162
|
+
// ⭐ REPLACE, never merge: every caller is a successful login, and there is one session.
|
|
163
|
+
// Whatever the file held — another backend's session, or several from before —
|
|
164
|
+
// is gone: the user is logged in to this backend, and only this one.
|
|
165
|
+
await writeAuthFile({ version: 2, current: key, sessions: { [key]: rest } })
|
|
105
166
|
}
|
|
106
167
|
|
|
107
168
|
/**
|
|
108
|
-
*
|
|
169
|
+
* Log out: delete the session file. There is one session, so there is nothing to choose.
|
|
170
|
+
*
|
|
171
|
+
* ⚠️ Local only — the token is forgotten on this machine, not revoked on the backend.
|
|
172
|
+
*
|
|
173
|
+
* @returns {Promise<string[]>} the origins whose sessions were removed — one, or
|
|
174
|
+
* several for a file written before sessions were single (2026-09-21)
|
|
109
175
|
*/
|
|
110
176
|
export async function clearRegistryAuth() {
|
|
111
177
|
const path = getRegistryAuthPath()
|
|
112
|
-
if (existsSync(path))
|
|
178
|
+
if (!existsSync(path)) return []
|
|
179
|
+
const had = Object.keys((await readAuthFile()).sessions)
|
|
180
|
+
await unlink(path)
|
|
181
|
+
return had
|
|
113
182
|
}
|
|
114
183
|
|
|
115
184
|
/**
|
|
@@ -190,6 +259,21 @@ export async function loginToRegistry({ apiBase, username, password } = {}) {
|
|
|
190
259
|
* @param {string[]} [options.args] - argv slice; checked for --non-interactive
|
|
191
260
|
* @returns {Promise<string>} bearer token
|
|
192
261
|
*/
|
|
262
|
+
/** `args` without `--token` and its value — see ensureRegistryAuth. */
|
|
263
|
+
export function withoutLoginToken(args = []) {
|
|
264
|
+
const out = []
|
|
265
|
+
for (let i = 0; i < args.length; i++) {
|
|
266
|
+
const a = args[i]
|
|
267
|
+
if (a === '--token') {
|
|
268
|
+
i++ // and its value
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
if (a.startsWith('--token=')) continue
|
|
272
|
+
out.push(a)
|
|
273
|
+
}
|
|
274
|
+
return out
|
|
275
|
+
}
|
|
276
|
+
|
|
193
277
|
export async function ensureRegistryAuth({
|
|
194
278
|
apiBase,
|
|
195
279
|
command = 'This command',
|
|
@@ -197,7 +281,11 @@ export async function ensureRegistryAuth({
|
|
|
197
281
|
} = {}) {
|
|
198
282
|
if (process.env.UNIWEB_TOKEN) return process.env.UNIWEB_TOKEN
|
|
199
283
|
|
|
200
|
-
|
|
284
|
+
// ⛔ THE SESSION MUST BE THE ONE ISSUED FOR `apiBase`. Until 2026-09-20 this read
|
|
285
|
+
// "the" session and returned its token whenever it was unexpired — so a bearer minted
|
|
286
|
+
// by backend A was sent to backend B, which rejects it. The store is keyed by origin
|
|
287
|
+
// now, so asking for the right one is the same single read.
|
|
288
|
+
const stored = await readRegistryAuth(apiBase)
|
|
201
289
|
if (stored?.token && !isExpired(stored)) return stored.token
|
|
202
290
|
|
|
203
291
|
// Non-interactive login from env (CI / agents) before any prompt.
|
|
@@ -240,7 +328,11 @@ export async function ensureRegistryAuth({
|
|
|
240
328
|
}
|
|
241
329
|
|
|
242
330
|
// Interactive: hand off to the multi-method login picker, reuse its session.
|
|
243
|
-
|
|
331
|
+
// ⛔ WITHOUT `--token`: in `runRegistryLogin` it means "seed and STORE this bearer as a
|
|
332
|
+
// session" — `uniweb login --token`'s job alone. A command's own argv reaching it
|
|
333
|
+
// would log the machine in as a side effect of that command. (The backend commands
|
|
334
|
+
// refuse `--token` since 2026-09-21; this holds for any other caller too.)
|
|
335
|
+
const record = await runRegistryLogin({ apiBase, args: withoutLoginToken(args) })
|
|
244
336
|
if (!record?.token) process.exit(1)
|
|
245
337
|
return record.token
|
|
246
338
|
}
|
|
@@ -562,25 +654,55 @@ async function loginViaBrowser({ apiBase }) {
|
|
|
562
654
|
* @returns {Promise<Object|undefined>} the stored session record
|
|
563
655
|
*/
|
|
564
656
|
export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
565
|
-
|
|
657
|
+
// For THIS backend. It read "the" session and printed `apiBase` beside it, so on a
|
|
658
|
+
// machine logged into another backend it announced "Already logged in … (this origin)"
|
|
659
|
+
// about a session belonging to a different one — and then offered to replace it.
|
|
660
|
+
const { isNonInteractive } = await import('./interactive.js')
|
|
661
|
+
const nonInteractive = isNonInteractive(args)
|
|
662
|
+
|
|
663
|
+
const key = normOrigin(apiBase)
|
|
664
|
+
const file = await readAuthFile()
|
|
665
|
+
const current = loggedInOriginOf(file)
|
|
666
|
+
const existing = await readRegistryAuth(apiBase)
|
|
667
|
+
const forced =
|
|
668
|
+
args.includes('--token') ||
|
|
669
|
+
args.includes('--browser') ||
|
|
670
|
+
args.includes('--password') ||
|
|
671
|
+
args.includes('--token-paste')
|
|
566
672
|
if (existing?.token && !isExpired(existing)) {
|
|
567
673
|
const who =
|
|
568
674
|
existing.username ||
|
|
569
675
|
existing.handle ||
|
|
570
676
|
(existing.uuid ? `account ${existing.uuid}` : '')
|
|
677
|
+
if (!forced) {
|
|
678
|
+
// Already logged in to this backend: nothing to do — logging in again is behind a
|
|
679
|
+
// method flag. (Also where a file from before sessions were single, holding this
|
|
680
|
+
// backend among others, becomes single: the rewrite keeps only this session.)
|
|
681
|
+
if (current !== key || Object.keys(file.sessions).length > 1) {
|
|
682
|
+
await writeRegistryAuth({ ...existing, origin: key })
|
|
683
|
+
}
|
|
684
|
+
console.error(
|
|
685
|
+
`\x1b[32m✓\x1b[0m Already logged in to ${key}${who ? ` as \x1b[1m${who}\x1b[0m` : ''}.`
|
|
686
|
+
)
|
|
687
|
+
console.error(
|
|
688
|
+
'\x1b[2mTo log in again, name a method: --password, --browser, --token-paste or --token <bearer>.\x1b[0m'
|
|
689
|
+
)
|
|
690
|
+
return { ...existing, origin: key }
|
|
691
|
+
}
|
|
571
692
|
console.error(
|
|
572
|
-
`Already logged in${who ? ` as \x1b[1m${who}\x1b[0m` : ''}
|
|
693
|
+
`Already logged in${who ? ` as \x1b[1m${who}\x1b[0m` : ''} (${key}) — logging in again replaces that session.\n`
|
|
573
694
|
)
|
|
574
|
-
|
|
695
|
+
} else if (current && current !== key) {
|
|
696
|
+
// ⭐ ONE SESSION AT A TIME *[Diego, 2026-09-21]*: logging in here logs you out of there
|
|
697
|
+
// — when this login succeeds (writeRegistryAuth replaces the file), so cancelling or
|
|
698
|
+
// failing leaves you logged in where you were.
|
|
699
|
+
console.error(`\x1b[2mYou are logged in to ${current}; logging in to ${key} logs you out of it.\x1b[0m\n`)
|
|
575
700
|
}
|
|
576
701
|
|
|
577
|
-
const { isNonInteractive } = await import('./interactive.js')
|
|
578
|
-
const nonInteractive = isNonInteractive(args)
|
|
579
|
-
|
|
580
702
|
// `--token <bearer>` seeds + verifies a session non-interactively (verified
|
|
581
703
|
// against /dev/auth/me before it's stored, so an invalid token fails loudly
|
|
582
|
-
// instead of poisoning the session file).
|
|
583
|
-
// `--token` (
|
|
704
|
+
// instead of poisoning the session file). ⭐ Login's alone: the backend commands
|
|
705
|
+
// take no `--token` (2026-09-21) — UNIWEB_TOKEN is the one-process bearer.
|
|
584
706
|
const { readFlagValue } = await import('./args.js')
|
|
585
707
|
const tokenFlag = readFlagValue(args, '--token')
|
|
586
708
|
if (tokenFlag) {
|
package/src/utils/scaffold.js
CHANGED
|
@@ -207,6 +207,15 @@ export async function applyContent(
|
|
|
207
207
|
)
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* What a project records about where IT synced and deployed — a site's identity on
|
|
212
|
+
* each backend, its deploy targets and history, and the local caches of both. A new
|
|
213
|
+
* project made from a template owns none of it: carried over, its first push would
|
|
214
|
+
* update the template's own site. The same line `uniweb forget --all` draws for a
|
|
215
|
+
* copied project. Matched at the top of a content directory, where a site keeps them.
|
|
216
|
+
*/
|
|
217
|
+
const INHERITED_STATE = new Set(['sync.json', 'deploy.yml', '.uniweb'])
|
|
218
|
+
|
|
210
219
|
/**
|
|
211
220
|
* Recursively copy content files, skipping structural files.
|
|
212
221
|
*
|
|
@@ -234,6 +243,7 @@ async function copyContentRecursive(
|
|
|
234
243
|
const sourcePath = join(sourceDir, entry.name)
|
|
235
244
|
|
|
236
245
|
if (entry.isDirectory()) {
|
|
246
|
+
if (depth === 0 && INHERITED_STATE.has(entry.name)) continue
|
|
237
247
|
const targetSubDir = join(targetDir, entry.name)
|
|
238
248
|
await copyContentRecursive(
|
|
239
249
|
sourcePath,
|
|
@@ -260,6 +270,7 @@ async function copyContentRecursive(
|
|
|
260
270
|
|
|
261
271
|
// Skip structural files
|
|
262
272
|
if (structuralFiles.has(outputName)) continue
|
|
273
|
+
if (depth === 0 && INHERITED_STATE.has(outputName)) continue
|
|
263
274
|
|
|
264
275
|
const targetPath = join(targetDir, outputName)
|
|
265
276
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `~/.uniweb/registry-auth.json` — the ONE reader of its shape.
|
|
3
|
+
*
|
|
4
|
+
* { version: 2, current: "<origin>", sessions: { "<origin>": { token, … } } }
|
|
5
|
+
*
|
|
6
|
+
* ⭐ ONE session since 2026-09-21 — a login replaces the file, a logout deletes it
|
|
7
|
+
* (registry-auth.js). The map shape stays so this reader also understands a file from
|
|
8
|
+
* 2026-09-20→21, when the store kept one session PER backend.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ **There were two readers until 2026-09-21, and the copy is how "the backend you are
|
|
11
|
+
* logged in to" stopped meaning anything.** `registry-auth.js` writes the file and read
|
|
12
|
+
* it; the origin ladder in `config.js` kept a sync copy that read a top-level `origin`.
|
|
13
|
+
* When the file became one session per backend (`fb4907e`), the writer moved to the shape
|
|
14
|
+
* above and the copy kept reading `origin` — a field the new shape does not have — so
|
|
15
|
+
* every command that falls back to the logged-in backend quietly fell through to the
|
|
16
|
+
* default one. Both now read through here, so the shape has one reader to change.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ Dependency-free, by requirement: `config.js` imports this, and `registry-auth.js`
|
|
19
|
+
* imports `config.js`, so this module can import neither.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
|
|
26
|
+
export const SESSION_FILE_VERSION = 2
|
|
27
|
+
|
|
28
|
+
export function sessionFilePath() {
|
|
29
|
+
return join(homedir(), '.uniweb', 'registry-auth.json')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const originOf = (value) => {
|
|
33
|
+
try {
|
|
34
|
+
// http(s) only — `localhost:8080` parses as a scheme with the origin "null".
|
|
35
|
+
const u = new URL(value)
|
|
36
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Any parsed value → `{ version: 2, current, sessions }`. Never throws.
|
|
44
|
+
*
|
|
45
|
+
* A v1 flat record is ONE session, keyed by its own `origin` stamp — or by
|
|
46
|
+
* `defaultOrigin` when it carries none — and that session is current: it was the only
|
|
47
|
+
* login there was.
|
|
48
|
+
*
|
|
49
|
+
* @param {*} raw - the parsed file, or anything
|
|
50
|
+
* @param {string} defaultOrigin
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeSessionFile(raw, defaultOrigin) {
|
|
53
|
+
const empty = { version: SESSION_FILE_VERSION, current: null, sessions: {} }
|
|
54
|
+
if (!raw || typeof raw !== 'object') return empty
|
|
55
|
+
if (raw.sessions && typeof raw.sessions === 'object' && !Array.isArray(raw.sessions)) {
|
|
56
|
+
return { version: SESSION_FILE_VERSION, current: originOf(raw.current), sessions: raw.sessions }
|
|
57
|
+
}
|
|
58
|
+
if (typeof raw.token === 'string') {
|
|
59
|
+
const { origin, ...rest } = raw
|
|
60
|
+
const key = originOf(origin) || defaultOrigin
|
|
61
|
+
return { version: SESSION_FILE_VERSION, current: key, sessions: { [key]: rest } }
|
|
62
|
+
}
|
|
63
|
+
return empty
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The file, normalized; synchronous, for the origin ladder. Never throws. */
|
|
67
|
+
export function readSessionFileSync(defaultOrigin) {
|
|
68
|
+
const p = sessionFilePath()
|
|
69
|
+
if (!existsSync(p)) return normalizeSessionFile(null, defaultOrigin)
|
|
70
|
+
try {
|
|
71
|
+
return normalizeSessionFile(JSON.parse(readFileSync(p, 'utf8')), defaultOrigin)
|
|
72
|
+
} catch {
|
|
73
|
+
return normalizeSessionFile(null, defaultOrigin)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* **The backend the user is logged in to** — the one session's backend.
|
|
79
|
+
*
|
|
80
|
+
* A file written since sessions became single holds one session, marked `current`. The
|
|
81
|
+
* other branches read what came before: a per-backend file names its last login
|
|
82
|
+
* `current`; one with a single unmarked session answers with it; several and none
|
|
83
|
+
* marked is no answer, and saying so beats guessing. An expired session still answers —
|
|
84
|
+
* the user logged in there, and the next request asks them to log in there again.
|
|
85
|
+
*
|
|
86
|
+
* @param {{ current?: string|null, sessions?: object }} file - normalized
|
|
87
|
+
* @returns {string|null}
|
|
88
|
+
*/
|
|
89
|
+
export function loggedInOriginOf(file) {
|
|
90
|
+
const sessions = file?.sessions || {}
|
|
91
|
+
const has = (o) => !!(o && sessions[o] && typeof sessions[o].token === 'string')
|
|
92
|
+
if (has(file?.current)) return file.current
|
|
93
|
+
const all = Object.keys(sessions).filter(has)
|
|
94
|
+
return all.length === 1 ? all[0] : null
|
|
95
|
+
}
|