@quonfig/cli 0.0.21 → 0.0.23
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/README.md +82 -35
- package/dist/commands/delete.d.ts +13 -0
- package/dist/commands/delete.js +114 -0
- package/dist/commands/pull.js +2 -2
- package/dist/commands/push.d.ts +6 -14
- package/dist/commands/push.js +54 -99
- package/dist/init/schema.js +4 -2
- package/dist/push/bare-path-diff.js +19 -17
- package/dist/push/config-push-client.d.ts +18 -0
- package/dist/push/config-push-client.js +82 -0
- package/dist/push/diff-summary.d.ts +11 -0
- package/dist/push/diff-summary.js +1 -8
- package/dist/push/run-push.d.ts +53 -13
- package/dist/push/run-push.js +44 -132
- package/dist/quonfig.js +2 -1
- package/dist/util/domain-urls.d.ts +1 -0
- package/dist/util/domain-urls.js +7 -0
- package/dist/util/gitea-token-storage.js +2 -2
- package/dist/util/quonfig-home.d.ts +6 -0
- package/dist/util/quonfig-home.js +8 -0
- package/dist/util/token-storage.js +2 -2
- package/dist/verify/validate.js +2 -2
- package/oclif.manifest.json +88 -1
- package/package.json +1 -1
package/dist/commands/push.js
CHANGED
|
@@ -5,9 +5,10 @@ import * as util from 'node:util';
|
|
|
5
5
|
import { Flags } from '@oclif/core';
|
|
6
6
|
import { BaseCommand } from '../index.js';
|
|
7
7
|
import { mintGiteaToken } from '../util/gitea-api.js';
|
|
8
|
-
import { getRemoteUrl, gitFetch, gitSetRemote, isGitRepo
|
|
9
|
-
import { PushFatalError, runPush } from '../push/run-push.js';
|
|
8
|
+
import { getRemoteUrl, gitFetch, gitSetRemote, isGitRepo } from '../util/git-ops.js';
|
|
9
|
+
import { PushFatalError, runPush, } from '../push/run-push.js';
|
|
10
10
|
import { computeBarePathDiff } from '../push/bare-path-diff.js';
|
|
11
|
+
import { callConfigsPush } from '../push/config-push-client.js';
|
|
11
12
|
import { resolveWorkspaceUuid } from '../util/resolve-workspace.js';
|
|
12
13
|
const execFile = util.promisify(execFileCb);
|
|
13
14
|
export default class Push extends BaseCommand {
|
|
@@ -127,6 +128,11 @@ export function giteaResponseToMintResult(resp) {
|
|
|
127
128
|
*
|
|
128
129
|
* Returns a `{deps, cleanup}` pair. The caller MUST call `cleanup()` in a
|
|
129
130
|
* finally block so the bare-path probe clone is removed even on errors.
|
|
131
|
+
*
|
|
132
|
+
* As of qfg-azk.13 the push code path no longer mints a write-scoped Gitea
|
|
133
|
+
* token; the actual commit is performed server-side by the `configs.push`
|
|
134
|
+
* oRPC procedure. We still mint a READ token to authenticate the bare-path
|
|
135
|
+
* probe-clone and the clone-path `git fetch`.
|
|
130
136
|
*/
|
|
131
137
|
export function buildRealDeps(cmd) {
|
|
132
138
|
const log = (line) => cmd.log(line);
|
|
@@ -187,12 +193,6 @@ export function buildRealDeps(cmd) {
|
|
|
187
193
|
const probe = await ensureBarePathProbe(dir);
|
|
188
194
|
return probe.deltas;
|
|
189
195
|
},
|
|
190
|
-
async push(dir) {
|
|
191
|
-
await execFile('git', ['-C', dir, 'push', 'origin', 'main']);
|
|
192
|
-
},
|
|
193
|
-
async getLocalAuthor(dir) {
|
|
194
|
-
return readLocalAuthor(dir);
|
|
195
|
-
},
|
|
196
196
|
async countFilesInRemote(dir) {
|
|
197
197
|
if (await isGitRepo(dir)) {
|
|
198
198
|
const fromGit = await countTrackedFilesAtRef(dir, 'origin/main');
|
|
@@ -207,9 +207,11 @@ export function buildRealDeps(cmd) {
|
|
|
207
207
|
};
|
|
208
208
|
const deps = {
|
|
209
209
|
async mintWriteToken(requestedTarget) {
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
//
|
|
210
|
+
// Read scope: this token authenticates probe-clone and clone-path
|
|
211
|
+
// fetch. The actual commit goes through the server via configs.push,
|
|
212
|
+
// which is authorized off the user's WorkOS session — no write PAT
|
|
213
|
+
// needed in the push code path (qfg-azk.13).
|
|
214
|
+
const resp = await mintGiteaToken(requestedTarget, 'read', 'pull');
|
|
213
215
|
authenticatedRepoUrl = resp.repoUrl;
|
|
214
216
|
return giteaResponseToMintResult(resp);
|
|
215
217
|
},
|
|
@@ -220,7 +222,7 @@ export function buildRealDeps(cmd) {
|
|
|
220
222
|
return { errors };
|
|
221
223
|
},
|
|
222
224
|
gitOps,
|
|
223
|
-
|
|
225
|
+
pushToServer: callConfigsPush,
|
|
224
226
|
log,
|
|
225
227
|
errLog,
|
|
226
228
|
};
|
|
@@ -237,26 +239,20 @@ export function buildRealDeps(cmd) {
|
|
|
237
239
|
return { deps, cleanup };
|
|
238
240
|
}
|
|
239
241
|
/**
|
|
240
|
-
* Diff the working tree (what we are about to push) against origin/main
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
242
|
+
* Diff the working tree (what we are about to push) against origin/main and
|
|
243
|
+
* fill in before/after JSON content for each delta as required by the
|
|
244
|
+
* `configs.push` wire shape (qfg-azk.13).
|
|
245
|
+
*
|
|
246
|
+
* Added — afterJson = working tree
|
|
247
|
+
* Modified — beforeJson = `git show origin/main:<path>`, afterJson = working tree
|
|
248
|
+
* Deleted — beforeJson = `git show origin/main:<path>`
|
|
245
249
|
*
|
|
246
250
|
* `git diff A..B` reports B relative to A. So `HEAD..origin/main` tells us
|
|
247
251
|
* what origin has that we don't. We want "local vs remote" with local as the
|
|
248
252
|
* baseline — i.e. origin/main..HEAD.
|
|
249
253
|
*/
|
|
250
254
|
async function diffHeadVsOrigin(dir) {
|
|
251
|
-
|
|
252
|
-
// 'A' -> added locally, 'D' -> deleted locally, 'M' -> modified locally.
|
|
253
|
-
const { stdout } = await execFile('git', [
|
|
254
|
-
'-C',
|
|
255
|
-
dir,
|
|
256
|
-
'diff',
|
|
257
|
-
'--name-status',
|
|
258
|
-
'origin/main..HEAD',
|
|
259
|
-
]);
|
|
255
|
+
const { stdout } = await execFile('git', ['-C', dir, 'diff', '--name-status', 'origin/main..HEAD']);
|
|
260
256
|
const deltas = [];
|
|
261
257
|
for (const raw of stdout.split('\n')) {
|
|
262
258
|
const line = raw.trim();
|
|
@@ -266,95 +262,54 @@ async function diffHeadVsOrigin(dir) {
|
|
|
266
262
|
const pathStr = rest.join(' ');
|
|
267
263
|
if (!pathStr)
|
|
268
264
|
continue;
|
|
269
|
-
if (status.startsWith('A'))
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
deltas.push({ kind: '
|
|
265
|
+
if (status.startsWith('A')) {
|
|
266
|
+
// eslint-disable-next-line no-await-in-loop
|
|
267
|
+
const afterJson = await readWorkingTreeFile(dir, pathStr);
|
|
268
|
+
deltas.push({ kind: 'added', path: pathStr, ...(afterJson === undefined ? {} : { afterJson }) });
|
|
269
|
+
}
|
|
270
|
+
else if (status.startsWith('D')) {
|
|
271
|
+
// eslint-disable-next-line no-await-in-loop
|
|
272
|
+
const beforeJson = await showAtRef(dir, 'origin/main', pathStr);
|
|
273
|
+
deltas.push({ kind: 'deleted', path: pathStr, ...(beforeJson === undefined ? {} : { beforeJson }) });
|
|
274
|
+
}
|
|
273
275
|
else if (status.startsWith('M') || status.startsWith('R') || status.startsWith('C')) {
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
+
// eslint-disable-next-line no-await-in-loop
|
|
277
|
+
const beforeJson = await showAtRef(dir, 'origin/main', pathStr);
|
|
278
|
+
// eslint-disable-next-line no-await-in-loop
|
|
279
|
+
const afterJson = await readWorkingTreeFile(dir, pathStr);
|
|
280
|
+
deltas.push({
|
|
281
|
+
kind: 'modified',
|
|
282
|
+
path: pathStr,
|
|
283
|
+
...(beforeJson === undefined ? {} : { beforeJson }),
|
|
284
|
+
...(afterJson === undefined ? {} : { afterJson }),
|
|
285
|
+
});
|
|
276
286
|
}
|
|
277
287
|
}
|
|
278
288
|
return deltas;
|
|
279
289
|
}
|
|
280
|
-
async function
|
|
290
|
+
async function showAtRef(dir, ref, relPath) {
|
|
281
291
|
try {
|
|
282
|
-
const { stdout } = await execFile('git', ['-C', dir, '
|
|
283
|
-
return stdout
|
|
292
|
+
const { stdout } = await execFile('git', ['-C', dir, 'show', `${ref}:${relPath}`]);
|
|
293
|
+
return stdout;
|
|
284
294
|
}
|
|
285
295
|
catch {
|
|
286
|
-
return
|
|
296
|
+
return undefined;
|
|
287
297
|
}
|
|
288
298
|
}
|
|
289
|
-
async function
|
|
299
|
+
async function readWorkingTreeFile(dir, relPath) {
|
|
290
300
|
try {
|
|
291
|
-
|
|
292
|
-
const { stdout: email } = await execFile('git', ['-C', dir, 'config', 'user.email']);
|
|
293
|
-
const n = name.trim();
|
|
294
|
-
const e = email.trim();
|
|
295
|
-
if (!n || !e)
|
|
296
|
-
return undefined;
|
|
297
|
-
return { name: n, email: e };
|
|
301
|
+
return await fs.promises.readFile(path.join(dir, relPath), 'utf8');
|
|
298
302
|
}
|
|
299
303
|
catch {
|
|
300
304
|
return undefined;
|
|
301
305
|
}
|
|
302
306
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
* `cloneAndStackPush`'s `git add --all` + diff will reflect the union of
|
|
308
|
-
* (deletes, modifies, adds) we intend.
|
|
309
|
-
*
|
|
310
|
-
* IMPORTANT: this deletes files in destDir that are not in sourceDir so that
|
|
311
|
-
* deletions make it into the push. Without this the clone-and-stack produces
|
|
312
|
-
* an "adds-only" delta and destructive heuristics would never fire in the UI.
|
|
313
|
-
*/
|
|
314
|
-
export async function copyDirMirror(sourceDir, destDir) {
|
|
315
|
-
// Collect source entries (except .git, .quonfig-push-clone-*)
|
|
316
|
-
const sourceRel = collectRelPaths(sourceDir);
|
|
317
|
-
const destRel = collectRelPaths(destDir);
|
|
318
|
-
// Delete anything in dest that isn't in source
|
|
319
|
-
for (const rel of destRel) {
|
|
320
|
-
if (!sourceRel.has(rel)) {
|
|
321
|
-
const full = path.join(destDir, rel);
|
|
322
|
-
try {
|
|
323
|
-
fs.rmSync(full, { force: true });
|
|
324
|
-
}
|
|
325
|
-
catch {
|
|
326
|
-
/* ignore */
|
|
327
|
-
}
|
|
328
|
-
}
|
|
307
|
+
async function countTrackedFilesAtRef(dir, ref) {
|
|
308
|
+
try {
|
|
309
|
+
const { stdout } = await execFile('git', ['-C', dir, 'ls-tree', '-r', '--name-only', ref]);
|
|
310
|
+
return stdout.split('\n').filter((l) => l.trim().length > 0).length;
|
|
329
311
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const src = path.join(sourceDir, rel);
|
|
333
|
-
const dst = path.join(destDir, rel);
|
|
334
|
-
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
335
|
-
fs.copyFileSync(src, dst);
|
|
312
|
+
catch {
|
|
313
|
+
return 0;
|
|
336
314
|
}
|
|
337
315
|
}
|
|
338
|
-
function collectRelPaths(root) {
|
|
339
|
-
const out = new Set();
|
|
340
|
-
if (!fs.existsSync(root))
|
|
341
|
-
return out;
|
|
342
|
-
const walk = (dir, prefix) => {
|
|
343
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
344
|
-
if (entry.name === '.git')
|
|
345
|
-
continue;
|
|
346
|
-
if (entry.name.startsWith('.quonfig-push-clone-'))
|
|
347
|
-
continue;
|
|
348
|
-
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
349
|
-
const full = path.join(dir, entry.name);
|
|
350
|
-
if (entry.isDirectory()) {
|
|
351
|
-
walk(full, rel);
|
|
352
|
-
}
|
|
353
|
-
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
354
|
-
out.add(rel);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
};
|
|
358
|
-
walk(root, '');
|
|
359
|
-
return out;
|
|
360
|
-
}
|
package/dist/init/schema.js
CHANGED
|
@@ -298,8 +298,10 @@ export function storedConfigJsonSchema() {
|
|
|
298
298
|
type: 'string',
|
|
299
299
|
description: 'References a schema in schemas/ or schemas-protected/. The referenced file must exist.',
|
|
300
300
|
},
|
|
301
|
-
|
|
302
|
-
|
|
301
|
+
access: {
|
|
302
|
+
enum: ['support', 'standard', 'protected-env', 'protected-all-envs'],
|
|
303
|
+
description: 'Edit-access tier for this config. Defaults to "standard" when absent. See protecting-access.md.',
|
|
304
|
+
},
|
|
303
305
|
tags: {
|
|
304
306
|
type: 'array',
|
|
305
307
|
items: { type: 'string' },
|
|
@@ -54,35 +54,37 @@ export async function computeBarePathDiff(localDir, remoteUrl) {
|
|
|
54
54
|
}
|
|
55
55
|
throw error;
|
|
56
56
|
}
|
|
57
|
-
const localFiles =
|
|
58
|
-
const remoteFiles =
|
|
57
|
+
const localFiles = collectFiles(localDir);
|
|
58
|
+
const remoteFiles = collectFiles(scratchDir);
|
|
59
59
|
const deltas = [];
|
|
60
|
-
for (const [rel,
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
deltas.push({ kind: 'added', path: rel });
|
|
60
|
+
for (const [rel, local] of localFiles) {
|
|
61
|
+
const remote = remoteFiles.get(rel);
|
|
62
|
+
if (remote === undefined) {
|
|
63
|
+
deltas.push({ kind: 'added', path: rel, afterJson: local.content });
|
|
64
64
|
}
|
|
65
|
-
else if (
|
|
66
|
-
deltas.push({ kind: 'modified', path: rel });
|
|
65
|
+
else if (remote.hash !== local.hash) {
|
|
66
|
+
deltas.push({ kind: 'modified', path: rel, beforeJson: remote.content, afterJson: local.content });
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
-
for (const rel of remoteFiles
|
|
69
|
+
for (const [rel, remote] of remoteFiles) {
|
|
70
70
|
if (!localFiles.has(rel)) {
|
|
71
|
-
deltas.push({ kind: 'deleted', path: rel });
|
|
71
|
+
deltas.push({ kind: 'deleted', path: rel, beforeJson: remote.content });
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
return { deltas, scratchDir, totalRemoteFiles: remoteFiles.size };
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
77
|
-
* Walk `root` and return a map of relative-path -> content
|
|
78
|
-
* non-dotfile file under it.
|
|
77
|
+
* Walk `root` and return a map of relative-path -> {content, hash} for every
|
|
78
|
+
* non-dotfile file under it. The content string is needed by qfg-azk.13's
|
|
79
|
+
* `configs.push` wire shape; the hash is used to short-circuit the
|
|
80
|
+
* unchanged-file case without comparing full bodies.
|
|
79
81
|
*
|
|
80
82
|
* We skip any entry whose name starts with a `.`, which covers `.git/`,
|
|
81
83
|
* `.DS_Store`, and the transient `.quonfig-push-clone-*` scratch dirs that
|
|
82
84
|
* `run-push.ts` creates next to the local workspace. Keeping the rule simple
|
|
83
85
|
* and symmetric on both sides prevents one-off false positives.
|
|
84
86
|
*/
|
|
85
|
-
function
|
|
87
|
+
function collectFiles(root) {
|
|
86
88
|
const out = new Map();
|
|
87
89
|
if (!fs.existsSync(root))
|
|
88
90
|
return out;
|
|
@@ -104,8 +106,8 @@ function collectFilesWithHash(root) {
|
|
|
104
106
|
}
|
|
105
107
|
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
106
108
|
try {
|
|
107
|
-
const
|
|
108
|
-
out.set(rel,
|
|
109
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
110
|
+
out.set(rel, { content, hash: hashString(content) });
|
|
109
111
|
}
|
|
110
112
|
catch {
|
|
111
113
|
/* ignore unreadable files — they won't appear on either side */
|
|
@@ -121,6 +123,6 @@ function collectFilesWithHash(root) {
|
|
|
121
123
|
* fingerprint so we can say "file A on local matches file A on remote". The
|
|
122
124
|
* contents are small JSON in practice, so sha1 is plenty fast.
|
|
123
125
|
*/
|
|
124
|
-
function
|
|
125
|
-
return createHash('sha1').update(
|
|
126
|
+
function hashString(s) {
|
|
127
|
+
return createHash('sha1').update(s).digest('hex');
|
|
126
128
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the `configs.push` oRPC procedure (qfg-azk.13).
|
|
3
|
+
*
|
|
4
|
+
* Server-side spec lives at
|
|
5
|
+
* `app-quonfig/src/lib/orpc/routes/config-push-handler.ts`. We translate the
|
|
6
|
+
* oRPC error shape into a tagged union the CLI can pattern-match on:
|
|
7
|
+
*
|
|
8
|
+
* - 200 → `{kind: 'success', commitSha}`
|
|
9
|
+
* - 403 with `data.denials` → `{kind: 'denied', denials}`
|
|
10
|
+
* - 409 → `{kind: 'conflict', message}`
|
|
11
|
+
* - 400 → `{kind: 'bad-request', message}`
|
|
12
|
+
* - anything else → throws so runPush surfaces a generic PushFatalError.
|
|
13
|
+
*
|
|
14
|
+
* The auth header is a WorkOS access token (or a `qf_uk_*` API key when
|
|
15
|
+
* `QUONFIG_API_KEY` is set in the environment); see `getValidAccessToken`.
|
|
16
|
+
*/
|
|
17
|
+
import type { ConfigPushInput, ConfigPushResult } from './run-push.js';
|
|
18
|
+
export declare const callConfigsPush: (input: ConfigPushInput) => Promise<ConfigPushResult>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the `configs.push` oRPC procedure (qfg-azk.13).
|
|
3
|
+
*
|
|
4
|
+
* Server-side spec lives at
|
|
5
|
+
* `app-quonfig/src/lib/orpc/routes/config-push-handler.ts`. We translate the
|
|
6
|
+
* oRPC error shape into a tagged union the CLI can pattern-match on:
|
|
7
|
+
*
|
|
8
|
+
* - 200 → `{kind: 'success', commitSha}`
|
|
9
|
+
* - 403 with `data.denials` → `{kind: 'denied', denials}`
|
|
10
|
+
* - 409 → `{kind: 'conflict', message}`
|
|
11
|
+
* - 400 → `{kind: 'bad-request', message}`
|
|
12
|
+
* - anything else → throws so runPush surfaces a generic PushFatalError.
|
|
13
|
+
*
|
|
14
|
+
* The auth header is a WorkOS access token (or a `qf_uk_*` API key when
|
|
15
|
+
* `QUONFIG_API_KEY` is set in the environment); see `getValidAccessToken`.
|
|
16
|
+
*/
|
|
17
|
+
import { getApiUrl } from '../util/domain-urls.js';
|
|
18
|
+
import { getValidAccessToken } from '../util/get-valid-token.js';
|
|
19
|
+
export const callConfigsPush = async (input) => {
|
|
20
|
+
const accessToken = await getValidAccessToken();
|
|
21
|
+
const apiUrl = getApiUrl();
|
|
22
|
+
const res = await fetch(`${apiUrl}/api/v1/configs/push`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: {
|
|
25
|
+
Authorization: `Bearer ${accessToken}`,
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify({ json: input }),
|
|
29
|
+
});
|
|
30
|
+
if (res.ok) {
|
|
31
|
+
const body = (await res.json());
|
|
32
|
+
const commitSha = body.json?.commitSha;
|
|
33
|
+
if (!commitSha) {
|
|
34
|
+
throw new Error(`configs.push returned 200 but no commitSha (body=${JSON.stringify(body)})`);
|
|
35
|
+
}
|
|
36
|
+
return { kind: 'success', commitSha };
|
|
37
|
+
}
|
|
38
|
+
const text = await res.text().catch(() => '');
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
const body = JSON.parse(text);
|
|
42
|
+
parsed = body.json ?? body;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* keep parsed undefined */
|
|
46
|
+
}
|
|
47
|
+
const message = parsed?.message ?? text ?? `HTTP ${res.status}`;
|
|
48
|
+
if (res.status === 403) {
|
|
49
|
+
const denials = extractDenials(parsed?.data);
|
|
50
|
+
if (denials.length > 0) {
|
|
51
|
+
return { kind: 'denied', denials };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (res.status === 409) {
|
|
55
|
+
return { kind: 'conflict', message };
|
|
56
|
+
}
|
|
57
|
+
if (res.status === 400) {
|
|
58
|
+
return { kind: 'bad-request', message };
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`configs.push failed (HTTP ${res.status}): ${message}`);
|
|
61
|
+
};
|
|
62
|
+
function extractDenials(data) {
|
|
63
|
+
if (!data || typeof data !== 'object')
|
|
64
|
+
return [];
|
|
65
|
+
const denials = data.denials;
|
|
66
|
+
if (!Array.isArray(denials))
|
|
67
|
+
return [];
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const raw of denials) {
|
|
70
|
+
if (!raw || typeof raw !== 'object')
|
|
71
|
+
continue;
|
|
72
|
+
const r = raw;
|
|
73
|
+
if (typeof r.path !== 'string' || typeof r.requiredPermission !== 'string')
|
|
74
|
+
continue;
|
|
75
|
+
out.push({
|
|
76
|
+
path: r.path,
|
|
77
|
+
reason: typeof r.reason === 'string' ? r.reason : `Missing required permission: ${r.requiredPermission}`,
|
|
78
|
+
requiredPermission: r.requiredPermission,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
@@ -14,6 +14,17 @@
|
|
|
14
14
|
*/
|
|
15
15
|
/** A single file-level change between the local working tree and remote HEAD. */
|
|
16
16
|
export interface FileDelta {
|
|
17
|
+
/**
|
|
18
|
+
* Local content for `added`/`modified`. Absent for `deleted`. Required by
|
|
19
|
+
* the `configs.push` server endpoint when sending the delta over the wire.
|
|
20
|
+
*/
|
|
21
|
+
afterJson?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Remote content for `modified`/`deleted`. Absent for `added`. Required by
|
|
24
|
+
* the `configs.push` server endpoint to deep-diff per-environment rule
|
|
25
|
+
* changes for authorization.
|
|
26
|
+
*/
|
|
27
|
+
beforeJson?: string;
|
|
17
28
|
/**
|
|
18
29
|
* `added` — present locally, absent on remote
|
|
19
30
|
* `modified` — present in both, content differs
|
|
@@ -16,14 +16,7 @@
|
|
|
16
16
|
* Top-level directories we present as named rows. Anything else funnels into
|
|
17
17
|
* `other`. Keeping this closed-set avoids one-off typos silently looking fine.
|
|
18
18
|
*/
|
|
19
|
-
const KNOWN_GROUPS = [
|
|
20
|
-
'configs',
|
|
21
|
-
'feature-flags',
|
|
22
|
-
'segments',
|
|
23
|
-
'schemas',
|
|
24
|
-
'schemas-protected',
|
|
25
|
-
'log-levels',
|
|
26
|
-
];
|
|
19
|
+
const KNOWN_GROUPS = ['configs', 'feature-flags', 'segments', 'schemas', 'schemas-protected', 'log-levels'];
|
|
27
20
|
const DESTRUCTIVE_DELETE_COUNT = 10;
|
|
28
21
|
const DESTRUCTIVE_DELETE_RATIO = 0.25;
|
|
29
22
|
function emptyCounts() {
|
package/dist/push/run-push.d.ts
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
* `--yes` skips the normal Y/N confirmation. It NEVER skips a typed-slug
|
|
19
19
|
* confirmation — that prompt is the destructive-change brake.
|
|
20
20
|
*/
|
|
21
|
-
import { PushIdentity } from '../util/clone-and-stack-push.js';
|
|
22
21
|
import { FileDelta } from './diff-summary.js';
|
|
23
22
|
export interface GiteaTokenMintResult {
|
|
24
23
|
expiresAt: string | null;
|
|
@@ -32,23 +31,52 @@ export interface GitOps {
|
|
|
32
31
|
countFilesInRemote(dir: string): Promise<number>;
|
|
33
32
|
/**
|
|
34
33
|
* Produce the list of file deltas to render in the Guard 3 summary and
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* send to the server. Each delta carries before/after JSON content per
|
|
35
|
+
* the `configs.push` wire shape (qfg-azk.13). For the clone path this is
|
|
36
|
+
* HEAD vs origin/main; for the bare path it's local vs probe-clone.
|
|
37
37
|
*/
|
|
38
38
|
diffHeadVsOrigin(dir: string): Promise<FileDelta[]>;
|
|
39
39
|
/** `git fetch origin` in the given dir. */
|
|
40
40
|
fetch(dir: string): Promise<void>;
|
|
41
|
-
/** Read local git config for author identity (used for bare-path commits). */
|
|
42
|
-
getLocalAuthor(dir: string): Promise<PushIdentity | undefined>;
|
|
43
41
|
/** Returns the `remote.origin.url` for the repo, or undefined if unset / not a repo. */
|
|
44
42
|
getRemoteOriginUrl(dir: string): Promise<string | undefined>;
|
|
45
43
|
/** Returns true if the dir has a `.git/` (worktree or repo). */
|
|
46
44
|
isGitRepo(dir: string): Promise<boolean>;
|
|
47
|
-
/** `git push origin main`. Throws with a PushConflict-ish message on non-ff. */
|
|
48
|
-
push(dir: string): Promise<void>;
|
|
49
45
|
/** Set origin to `url` (add if missing, set-url if present). */
|
|
50
46
|
setRemoteOrigin(dir: string, url: string): Promise<void>;
|
|
51
47
|
}
|
|
48
|
+
/** Server-side `kind` enum (matches `FileDeltaSchema` in app-quonfig). */
|
|
49
|
+
export type ServerFileKind = 'add' | 'delete' | 'modify';
|
|
50
|
+
export interface ServerFileDelta {
|
|
51
|
+
afterJson?: string;
|
|
52
|
+
beforeJson?: string;
|
|
53
|
+
kind: ServerFileKind;
|
|
54
|
+
path: string;
|
|
55
|
+
}
|
|
56
|
+
export interface ConfigPushInput {
|
|
57
|
+
expectedSha?: string;
|
|
58
|
+
files: ServerFileDelta[];
|
|
59
|
+
message?: string;
|
|
60
|
+
workspaceId: string;
|
|
61
|
+
}
|
|
62
|
+
export type ConfigPushResult = {
|
|
63
|
+
kind: 'bad-request';
|
|
64
|
+
message: string;
|
|
65
|
+
} | {
|
|
66
|
+
kind: 'conflict';
|
|
67
|
+
message: string;
|
|
68
|
+
} | {
|
|
69
|
+
commitSha: string;
|
|
70
|
+
kind: 'success';
|
|
71
|
+
} | {
|
|
72
|
+
denials: PushDenial[];
|
|
73
|
+
kind: 'denied';
|
|
74
|
+
};
|
|
75
|
+
export interface PushDenial {
|
|
76
|
+
path: string;
|
|
77
|
+
reason: string;
|
|
78
|
+
requiredPermission: string;
|
|
79
|
+
}
|
|
52
80
|
export type ConfirmIO = {
|
|
53
81
|
input?: NodeJS.ReadableStream;
|
|
54
82
|
output?: NodeJS.WritableStream;
|
|
@@ -70,18 +98,30 @@ export interface RunPushInput {
|
|
|
70
98
|
export interface RunPushDeps {
|
|
71
99
|
/** Optional io streams for confirmation prompts. Defaults to process stdin/stdout. */
|
|
72
100
|
confirmIO?: ConfirmIO;
|
|
73
|
-
/**
|
|
74
|
-
* Copy files from `sourceDir` into `destDir`, skipping `.git/`. Used by the
|
|
75
|
-
* bare path as the `applyDelta` callback handed to `cloneAndStackPush`.
|
|
76
|
-
*/
|
|
77
|
-
copyDirMirror(sourceDir: string, destDir: string): Promise<void>;
|
|
78
101
|
/** Error logger; defaults to console.error. */
|
|
79
102
|
errLog?: (line: string) => void;
|
|
80
103
|
gitOps: GitOps;
|
|
81
104
|
/** Logger; defaults to console.log. */
|
|
82
105
|
log?: (line: string) => void;
|
|
83
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the backend's identity for this workspace. Returns repoUrl,
|
|
108
|
+
* workspaceSlug, workspaceId — used by the identity check and (for
|
|
109
|
+
* read-only auth) by the bare-path probe-clone and clone-path fetch.
|
|
110
|
+
*
|
|
111
|
+
* Named `mintWriteToken` for historical reasons; as of qfg-azk.13 the
|
|
112
|
+
* push code path no longer mints a write-scoped Gitea token (server-side
|
|
113
|
+
* commit via `configs.push`), so the real implementation now mints a
|
|
114
|
+
* read-scope token. The name is preserved to keep the test harness shape
|
|
115
|
+
* stable across the qfg-azk.13 transition.
|
|
116
|
+
*/
|
|
84
117
|
mintWriteToken(requestedTarget: string): Promise<GiteaTokenMintResult>;
|
|
118
|
+
/**
|
|
119
|
+
* Call the server-side `configs.push` oRPC procedure. Returns a tagged
|
|
120
|
+
* union so callers can distinguish success / per-file denials / a stale
|
|
121
|
+
* expectedSha conflict / a path allow-list violation without parsing
|
|
122
|
+
* HTTP status codes.
|
|
123
|
+
*/
|
|
124
|
+
pushToServer(input: ConfigPushInput): Promise<ConfigPushResult>;
|
|
85
125
|
/** Run `qfg validate` semantics. Should throw on error. */
|
|
86
126
|
validate(dir: string): Promise<{
|
|
87
127
|
errors: string[];
|