@quonfig/cli 0.0.15 → 0.0.16
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 +85 -34
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +18 -0
- package/dist/commands/migrate.js +1 -1
- package/dist/commands/pull.d.ts +14 -1
- package/dist/commands/pull.js +64 -4
- package/dist/commands/push.d.ts +53 -0
- package/dist/commands/push.js +370 -0
- package/dist/commands/workspace/bootstrap.d.ts +7 -0
- package/dist/commands/workspace/bootstrap.js +58 -2
- package/dist/migrate/local-write.d.ts +1 -1
- package/dist/migrate/local-write.js +1 -1
- package/dist/migrate/push-to-cloud.d.ts +1 -1
- package/dist/migrate/push-to-cloud.js +1 -1
- package/dist/push/bare-path-diff.d.ts +40 -0
- package/dist/push/bare-path-diff.js +126 -0
- package/dist/push/confirm.d.ts +10 -0
- package/dist/push/confirm.js +108 -0
- package/dist/push/diff-summary.d.ts +56 -0
- package/dist/push/diff-summary.js +115 -0
- package/dist/push/identity-check.d.ts +53 -0
- package/dist/push/identity-check.js +135 -0
- package/dist/push/run-push.d.ts +107 -0
- package/dist/push/run-push.js +318 -0
- package/dist/util/gitea-api.d.ts +15 -1
- package/dist/util/gitea-api.js +1 -0
- package/dist/util/gitea-token-storage.d.ts +7 -0
- package/dist/util/quonfig-json.d.ts +44 -0
- package/dist/util/quonfig-json.js +65 -0
- package/dist/verify/validate.js +6 -2
- package/oclif.manifest.json +104 -1
- package/package.json +1 -1
- /package/dist/{migrate/push-strategy.d.ts → util/clone-and-stack-push.d.ts} +0 -0
- /package/dist/{migrate/push-strategy.js → util/clone-and-stack-push.js} +0 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as util from 'node:util';
|
|
5
|
+
import { Flags } from '@oclif/core';
|
|
6
|
+
import { BaseCommand } from '../index.js';
|
|
7
|
+
import { mintGiteaToken } from '../util/gitea-api.js';
|
|
8
|
+
import { getRemoteUrl, gitFetch, gitSetRemote, isGitRepo, } from '../util/git-ops.js';
|
|
9
|
+
import { PushFatalError, runPush } from '../push/run-push.js';
|
|
10
|
+
import { computeBarePathDiff } from '../push/bare-path-diff.js';
|
|
11
|
+
import { getActiveProfile, loadAuthConfig } from '../util/token-storage.js';
|
|
12
|
+
const execFile = util.promisify(execFileCb);
|
|
13
|
+
export default class Push extends BaseCommand {
|
|
14
|
+
static description = `Push local config changes up to your workspace on Quonfig cloud.
|
|
15
|
+
|
|
16
|
+
Enforces three guards before touching the remote:
|
|
17
|
+
1. The dir's quonfig.json "workspace" pin must match the backend (if set).
|
|
18
|
+
2. The dir's git origin must match the backend repo URL (if set).
|
|
19
|
+
3. A diff summary is shown; destructive changes (10+ deletes, >=25% of
|
|
20
|
+
files, or an unpinned dir) require typing the workspace slug to confirm.
|
|
21
|
+
|
|
22
|
+
qfg push # uses QUONFIG_DIR + profile workspace
|
|
23
|
+
qfg push --dir ./our-config
|
|
24
|
+
qfg push --dir ./our-config --workspace acme-prod
|
|
25
|
+
qfg push --dir ./our-config --yes # skip normal Y/N (never skips typed-slug)
|
|
26
|
+
qfg push --dir ./our-config --skip-validate`;
|
|
27
|
+
static examples = [
|
|
28
|
+
'<%= config.bin %> push --dir ./our-config',
|
|
29
|
+
'<%= config.bin %> push --workspace acme-prod --dir ./our-config',
|
|
30
|
+
'<%= config.bin %> push --dir ./our-config --yes',
|
|
31
|
+
];
|
|
32
|
+
static flags = {
|
|
33
|
+
dir: Flags.string({
|
|
34
|
+
description: 'Local directory to push (defaults to QUONFIG_DIR env var)',
|
|
35
|
+
}),
|
|
36
|
+
workspace: Flags.string({
|
|
37
|
+
description: 'Workspace slug or UUID (defaults to active profile)',
|
|
38
|
+
}),
|
|
39
|
+
yes: Flags.boolean({
|
|
40
|
+
default: false,
|
|
41
|
+
description: 'Skip the standard Y/N confirm. Never skips typed-slug prompts.',
|
|
42
|
+
}),
|
|
43
|
+
'skip-validate': Flags.boolean({
|
|
44
|
+
default: false,
|
|
45
|
+
description: 'Skip `qfg validate` preflight',
|
|
46
|
+
}),
|
|
47
|
+
'no-pin-write': Flags.boolean({
|
|
48
|
+
default: false,
|
|
49
|
+
description: 'Do not offer to write workspace slug into quonfig.json on success',
|
|
50
|
+
}),
|
|
51
|
+
message: Flags.string({
|
|
52
|
+
char: 'm',
|
|
53
|
+
description: 'Commit message for bare-path pushes (ignored on clone-path; push what is already committed)',
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
async run() {
|
|
57
|
+
const { flags } = await this.parse(Push);
|
|
58
|
+
const dir = flags.dir || process.env.QUONFIG_DIR;
|
|
59
|
+
if (!dir) {
|
|
60
|
+
return this.err('No directory specified. Use --dir <path> or set QUONFIG_DIR.');
|
|
61
|
+
}
|
|
62
|
+
const resolvedDir = path.resolve(dir);
|
|
63
|
+
// Resolve requested target: --workspace overrides the active profile.
|
|
64
|
+
let requestedTarget = flags.workspace;
|
|
65
|
+
if (!requestedTarget) {
|
|
66
|
+
const authConfig = await loadAuthConfig();
|
|
67
|
+
if (!authConfig) {
|
|
68
|
+
return this.err('No active profile workspace; pass --workspace or run `qfg login`.');
|
|
69
|
+
}
|
|
70
|
+
const activeProfile = getActiveProfile();
|
|
71
|
+
const profile = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
|
|
72
|
+
if (!profile || !profile.workspace) {
|
|
73
|
+
return this.err('No active profile workspace; pass --workspace or run `qfg login`.');
|
|
74
|
+
}
|
|
75
|
+
requestedTarget = profile.workspace;
|
|
76
|
+
}
|
|
77
|
+
const { deps, cleanup } = buildRealDeps(this);
|
|
78
|
+
try {
|
|
79
|
+
const result = await runPush({
|
|
80
|
+
dir: resolvedDir,
|
|
81
|
+
requestedTarget,
|
|
82
|
+
yes: flags.yes,
|
|
83
|
+
skipValidate: flags['skip-validate'],
|
|
84
|
+
noPinWrite: flags['no-pin-write'],
|
|
85
|
+
message: flags.message,
|
|
86
|
+
}, deps);
|
|
87
|
+
if (result.kind === 'aborted') {
|
|
88
|
+
this.log(`Aborted: ${result.reason}`);
|
|
89
|
+
return { aborted: true, reason: result.reason };
|
|
90
|
+
}
|
|
91
|
+
if (result.kind === 'no-op') {
|
|
92
|
+
this.log(result.reason);
|
|
93
|
+
return { noop: true, reason: result.reason };
|
|
94
|
+
}
|
|
95
|
+
this.log(`Pushed via ${result.dispatchedAs}.`);
|
|
96
|
+
return { pushed: true, via: result.dispatchedAs, commitSha: result.commitSha ?? null };
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error instanceof PushFatalError) {
|
|
100
|
+
return this.err(error.message);
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
await cleanup();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Map a raw `gitea.token` API response into the `GiteaTokenMintResult` shape
|
|
111
|
+
* the push core (`runPush`) consumes. Kept as a pure function so we can unit
|
|
112
|
+
* test the wiring without a network — specifically, to regression-test that
|
|
113
|
+
* `workspaceId` on the result is the UUID the server returned, not the slug.
|
|
114
|
+
*
|
|
115
|
+
* The bug this guards against: before the backend started returning
|
|
116
|
+
* `workspaceId`, this module used `resp.workspaceSlug` as the `workspaceId`
|
|
117
|
+
* field, which caused `checkIdentity` to fail when the user passed
|
|
118
|
+
* `--workspace <UUID>` (requested=UUID, backend.workspaceId=slug → mismatch).
|
|
119
|
+
*/
|
|
120
|
+
export function giteaResponseToMintResult(resp) {
|
|
121
|
+
return {
|
|
122
|
+
token: resp.token,
|
|
123
|
+
repoUrl: resp.repoUrl,
|
|
124
|
+
expiresAt: resp.expiresAt,
|
|
125
|
+
workspaceSlug: resp.workspaceSlug,
|
|
126
|
+
// Canonical workspace UUID from the server. checkIdentity compares this
|
|
127
|
+
// against `requestedTarget` — so if the user passed --workspace as a
|
|
128
|
+
// UUID, the UUID path matches backend.workspaceId; if they passed a
|
|
129
|
+
// slug, the slug path matches backend.workspaceSlug. Either way the
|
|
130
|
+
// backend row is the same workspace.
|
|
131
|
+
workspaceId: resp.workspaceId,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Wire the real implementations of every dependency runPush needs. Kept in
|
|
136
|
+
* the command file so the pure core stays free of oclif / git shell imports.
|
|
137
|
+
*
|
|
138
|
+
* Returns a `{deps, cleanup}` pair. The caller MUST call `cleanup()` in a
|
|
139
|
+
* finally block so the bare-path probe clone is removed even on errors.
|
|
140
|
+
*/
|
|
141
|
+
export function buildRealDeps(cmd) {
|
|
142
|
+
const log = (line) => cmd.log(line);
|
|
143
|
+
const errLog = (line) => cmd.logToStderr(line);
|
|
144
|
+
// State captured across calls so a bare-path diff can share its probe clone
|
|
145
|
+
// between `diffHeadVsOrigin` and `countFilesInRemote`. Populated lazily on
|
|
146
|
+
// the first call that needs it.
|
|
147
|
+
//
|
|
148
|
+
// `authenticatedRepoUrl` is set by the wrapped `mintWriteToken` below —
|
|
149
|
+
// runPush always calls mint before it calls any gitOps method that needs
|
|
150
|
+
// the probe clone, so by the time we read it here it is populated.
|
|
151
|
+
let authenticatedRepoUrl;
|
|
152
|
+
let barePathProbe;
|
|
153
|
+
const ensureBarePathProbe = async (dir) => {
|
|
154
|
+
if (barePathProbe)
|
|
155
|
+
return barePathProbe;
|
|
156
|
+
if (!authenticatedRepoUrl) {
|
|
157
|
+
throw new Error('buildRealDeps: bare-path diff requested before mintWriteToken ran. This is a bug in runPush ordering.');
|
|
158
|
+
}
|
|
159
|
+
barePathProbe = await computeBarePathDiff(dir, authenticatedRepoUrl);
|
|
160
|
+
return barePathProbe;
|
|
161
|
+
};
|
|
162
|
+
const gitOps = {
|
|
163
|
+
async isGitRepo(dir) {
|
|
164
|
+
return isGitRepo(dir);
|
|
165
|
+
},
|
|
166
|
+
async getRemoteOriginUrl(dir) {
|
|
167
|
+
const url = await getRemoteUrl(dir);
|
|
168
|
+
return url ?? undefined;
|
|
169
|
+
},
|
|
170
|
+
async setRemoteOrigin(dir, url) {
|
|
171
|
+
await gitSetRemote(dir, url);
|
|
172
|
+
},
|
|
173
|
+
async fetch(dir) {
|
|
174
|
+
// fetch is only meaningful for the clone path. For a bare-path dir it
|
|
175
|
+
// is still called by runPush but the dir has no origin, so we swallow
|
|
176
|
+
// the failure here rather than in runPush. The probe-clone strategy
|
|
177
|
+
// handles the remote-state read.
|
|
178
|
+
try {
|
|
179
|
+
await gitFetch(dir);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* bare path: no origin to fetch from — intentional no-op */
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
async diffHeadVsOrigin(dir) {
|
|
186
|
+
// Clone path: .git/ exists AND origin matches backend. Prefer the native
|
|
187
|
+
// git diff; it is fast and doesn't require a second clone.
|
|
188
|
+
if (await isGitRepo(dir)) {
|
|
189
|
+
try {
|
|
190
|
+
return await diffHeadVsOrigin(dir);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// Fall through to the probe-clone path. This can happen if the dir
|
|
194
|
+
// is a git repo but origin doesn't have a main branch fetched, etc.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const probe = await ensureBarePathProbe(dir);
|
|
198
|
+
return probe.deltas;
|
|
199
|
+
},
|
|
200
|
+
async push(dir) {
|
|
201
|
+
await execFile('git', ['-C', dir, 'push', 'origin', 'main']);
|
|
202
|
+
},
|
|
203
|
+
async getLocalAuthor(dir) {
|
|
204
|
+
return readLocalAuthor(dir);
|
|
205
|
+
},
|
|
206
|
+
async countFilesInRemote(dir) {
|
|
207
|
+
if (await isGitRepo(dir)) {
|
|
208
|
+
const fromGit = await countTrackedFilesAtRef(dir, 'origin/main');
|
|
209
|
+
// If origin/main is present we get a positive count; if the dir is a
|
|
210
|
+
// bare path with a stale .git we get 0 and fall through.
|
|
211
|
+
if (fromGit > 0)
|
|
212
|
+
return fromGit;
|
|
213
|
+
}
|
|
214
|
+
const probe = await ensureBarePathProbe(dir);
|
|
215
|
+
return probe.totalRemoteFiles;
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
const deps = {
|
|
219
|
+
async mintWriteToken(requestedTarget) {
|
|
220
|
+
const resp = await mintGiteaToken(requestedTarget, 'write', 'push');
|
|
221
|
+
// Stash the authenticated URL so the bare-path gitOps methods can
|
|
222
|
+
// probe-clone it without a second mint.
|
|
223
|
+
authenticatedRepoUrl = resp.repoUrl;
|
|
224
|
+
return giteaResponseToMintResult(resp);
|
|
225
|
+
},
|
|
226
|
+
async validate(dir) {
|
|
227
|
+
const { validateWorkspace } = await import('../verify/validate.js');
|
|
228
|
+
const result = validateWorkspace(dir);
|
|
229
|
+
const errors = result.issues.filter((i) => i.severity === 'error').map((i) => i.message);
|
|
230
|
+
return { errors };
|
|
231
|
+
},
|
|
232
|
+
gitOps,
|
|
233
|
+
copyDirMirror,
|
|
234
|
+
log,
|
|
235
|
+
errLog,
|
|
236
|
+
};
|
|
237
|
+
const cleanup = async () => {
|
|
238
|
+
if (!barePathProbe)
|
|
239
|
+
return;
|
|
240
|
+
try {
|
|
241
|
+
fs.rmSync(barePathProbe.scratchDir, { force: true, recursive: true });
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
/* ignore — cleanup is best-effort */
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
return { deps, cleanup };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Diff the working tree (what we are about to push) against origin/main. We
|
|
251
|
+
* use `git diff --name-status HEAD..origin/main` with inverted semantics:
|
|
252
|
+
* Added here = present locally (in HEAD), absent in origin/main.
|
|
253
|
+
* Deleted = absent locally, present in origin/main.
|
|
254
|
+
* Modified = content differs.
|
|
255
|
+
*
|
|
256
|
+
* `git diff A..B` reports B relative to A. So `HEAD..origin/main` tells us
|
|
257
|
+
* what origin has that we don't. We want "local vs remote" with local as the
|
|
258
|
+
* baseline — i.e. origin/main..HEAD.
|
|
259
|
+
*/
|
|
260
|
+
async function diffHeadVsOrigin(dir) {
|
|
261
|
+
// origin/main..HEAD reports changes HEAD has relative to origin/main.
|
|
262
|
+
// 'A' -> added locally, 'D' -> deleted locally, 'M' -> modified locally.
|
|
263
|
+
const { stdout } = await execFile('git', [
|
|
264
|
+
'-C',
|
|
265
|
+
dir,
|
|
266
|
+
'diff',
|
|
267
|
+
'--name-status',
|
|
268
|
+
'origin/main..HEAD',
|
|
269
|
+
]);
|
|
270
|
+
const deltas = [];
|
|
271
|
+
for (const raw of stdout.split('\n')) {
|
|
272
|
+
const line = raw.trim();
|
|
273
|
+
if (!line)
|
|
274
|
+
continue;
|
|
275
|
+
const [status, ...rest] = line.split(/\s+/);
|
|
276
|
+
const pathStr = rest.join(' ');
|
|
277
|
+
if (!pathStr)
|
|
278
|
+
continue;
|
|
279
|
+
if (status.startsWith('A'))
|
|
280
|
+
deltas.push({ kind: 'added', path: pathStr });
|
|
281
|
+
else if (status.startsWith('D'))
|
|
282
|
+
deltas.push({ kind: 'deleted', path: pathStr });
|
|
283
|
+
else if (status.startsWith('M') || status.startsWith('R') || status.startsWith('C')) {
|
|
284
|
+
// Treat renames / copies as modifications for diff-summary purposes.
|
|
285
|
+
deltas.push({ kind: 'modified', path: pathStr });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return deltas;
|
|
289
|
+
}
|
|
290
|
+
async function countTrackedFilesAtRef(dir, ref) {
|
|
291
|
+
try {
|
|
292
|
+
const { stdout } = await execFile('git', ['-C', dir, 'ls-tree', '-r', '--name-only', ref]);
|
|
293
|
+
return stdout.split('\n').filter((l) => l.trim().length > 0).length;
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async function readLocalAuthor(dir) {
|
|
300
|
+
try {
|
|
301
|
+
const { stdout: name } = await execFile('git', ['-C', dir, 'config', 'user.name']);
|
|
302
|
+
const { stdout: email } = await execFile('git', ['-C', dir, 'config', 'user.email']);
|
|
303
|
+
const n = name.trim();
|
|
304
|
+
const e = email.trim();
|
|
305
|
+
if (!n || !e)
|
|
306
|
+
return undefined;
|
|
307
|
+
return { name: n, email: e };
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Copy the contents of `sourceDir` into `destDir`, skipping `.git/` and any
|
|
315
|
+
* temp push-clone scratch directories. Used as the `applyDelta` callback for
|
|
316
|
+
* bare-path pushes — we mirror local files into a fresh clone so that
|
|
317
|
+
* `cloneAndStackPush`'s `git add --all` + diff will reflect the union of
|
|
318
|
+
* (deletes, modifies, adds) we intend.
|
|
319
|
+
*
|
|
320
|
+
* IMPORTANT: this deletes files in destDir that are not in sourceDir so that
|
|
321
|
+
* deletions make it into the push. Without this the clone-and-stack produces
|
|
322
|
+
* an "adds-only" delta and destructive heuristics would never fire in the UI.
|
|
323
|
+
*/
|
|
324
|
+
export async function copyDirMirror(sourceDir, destDir) {
|
|
325
|
+
// Collect source entries (except .git, .quonfig-push-clone-*)
|
|
326
|
+
const sourceRel = collectRelPaths(sourceDir);
|
|
327
|
+
const destRel = collectRelPaths(destDir);
|
|
328
|
+
// Delete anything in dest that isn't in source
|
|
329
|
+
for (const rel of destRel) {
|
|
330
|
+
if (!sourceRel.has(rel)) {
|
|
331
|
+
const full = path.join(destDir, rel);
|
|
332
|
+
try {
|
|
333
|
+
fs.rmSync(full, { force: true });
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
/* ignore */
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Copy / overwrite everything from source into dest
|
|
341
|
+
for (const rel of sourceRel) {
|
|
342
|
+
const src = path.join(sourceDir, rel);
|
|
343
|
+
const dst = path.join(destDir, rel);
|
|
344
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
345
|
+
fs.copyFileSync(src, dst);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function collectRelPaths(root) {
|
|
349
|
+
const out = new Set();
|
|
350
|
+
if (!fs.existsSync(root))
|
|
351
|
+
return out;
|
|
352
|
+
const walk = (dir, prefix) => {
|
|
353
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
354
|
+
if (entry.name === '.git')
|
|
355
|
+
continue;
|
|
356
|
+
if (entry.name.startsWith('.quonfig-push-clone-'))
|
|
357
|
+
continue;
|
|
358
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
359
|
+
const full = path.join(dir, entry.name);
|
|
360
|
+
if (entry.isDirectory()) {
|
|
361
|
+
walk(full, rel);
|
|
362
|
+
}
|
|
363
|
+
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
364
|
+
out.add(rel);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
walk(root, '');
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
@@ -9,5 +9,12 @@ export default class WorkspaceBootstrap extends BaseCommand {
|
|
|
9
9
|
'skip-validate': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
10
10
|
};
|
|
11
11
|
run(): Promise<JsonObj | void>;
|
|
12
|
+
/**
|
|
13
|
+
* Stage, commit, and push the `quonfig.json` pin. Runs after the main
|
|
14
|
+
* bootstrap push so the pin lands as a committed+pushed change rather than
|
|
15
|
+
* an uncommitted local edit. We stage only `quonfig.json` (not `-A`) so we
|
|
16
|
+
* don't accidentally sweep in unrelated user edits.
|
|
17
|
+
*/
|
|
18
|
+
private commitAndPushPin;
|
|
12
19
|
private remoteHasCommits;
|
|
13
20
|
}
|
|
@@ -4,6 +4,7 @@ import { Flags } from '@oclif/core';
|
|
|
4
4
|
import { BaseCommand } from '../../index.js';
|
|
5
5
|
import { getActiveProfile, loadAuthConfig } from '../../util/token-storage.js';
|
|
6
6
|
import { mintGiteaToken } from '../../util/gitea-api.js';
|
|
7
|
+
import { readWorkspaceSlug, writeWorkspaceSlug } from '../../util/quonfig-json.js';
|
|
7
8
|
import { isGitRepo, hasAtLeastOneCommit, gitSetRemote, gitPushForceLease, gitPushForce, getRemoteUrl, displayUrl, } from '../../util/git-ops.js';
|
|
8
9
|
export default class WorkspaceBootstrap extends BaseCommand {
|
|
9
10
|
static description = "Push a local git repo to Gitea as this workspace's config repository";
|
|
@@ -102,8 +103,8 @@ export default class WorkspaceBootstrap extends BaseCommand {
|
|
|
102
103
|
catch (error) {
|
|
103
104
|
return this.err(`Could not get Gitea credentials: ${String(error)}\n\nMake sure the workspace is fully provisioned in the Quonfig app before bootstrapping.`);
|
|
104
105
|
}
|
|
105
|
-
const { repoUrl } = tokenData;
|
|
106
|
-
this.verboseLog('WorkspaceBootstrap', { repoUrl: displayUrl(repoUrl) });
|
|
106
|
+
const { repoUrl, workspaceSlug: backendSlug } = tokenData;
|
|
107
|
+
this.verboseLog('WorkspaceBootstrap', { repoUrl: displayUrl(repoUrl), backendSlug });
|
|
107
108
|
// Idempotency: check if remote already has commits
|
|
108
109
|
if (!flags.force) {
|
|
109
110
|
const remoteHasCommits = await this.remoteHasCommits(repoUrl);
|
|
@@ -135,6 +136,35 @@ export default class WorkspaceBootstrap extends BaseCommand {
|
|
|
135
136
|
catch (error) {
|
|
136
137
|
return this.err(`Push failed: ${String(error)}`);
|
|
137
138
|
}
|
|
139
|
+
// Write the workspace pin into `quonfig.json` (Guard 1 in
|
|
140
|
+
// project/plans/cli-git-sync.md). If the pin already exists and disagrees,
|
|
141
|
+
// we keep what is there — bootstrap is for fresh cases, not re-pinning.
|
|
142
|
+
// If we do write it, commit and push so the bootstrap leaves no
|
|
143
|
+
// uncommitted changes behind.
|
|
144
|
+
try {
|
|
145
|
+
const existingPin = await readWorkspaceSlug(resolvedDir);
|
|
146
|
+
if (existingPin && existingPin !== backendSlug) {
|
|
147
|
+
this.log('');
|
|
148
|
+
this.log(`Warning: quonfig.json already pins workspace "${existingPin}", but the backend says this workspace is "${backendSlug}".`);
|
|
149
|
+
this.log('Leaving the existing pin in place. If this is wrong, edit quonfig.json manually and re-run.');
|
|
150
|
+
}
|
|
151
|
+
else if (existingPin === backendSlug) {
|
|
152
|
+
this.verboseLog('WorkspaceBootstrap', `quonfig.json already pinned to ${backendSlug}; no-op.`);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
this.log('');
|
|
156
|
+
this.log(`Pinning quonfig.json to workspace "${backendSlug}"...`);
|
|
157
|
+
await writeWorkspaceSlug(resolvedDir, backendSlug);
|
|
158
|
+
await this.commitAndPushPin(resolvedDir, backendSlug, flags.force);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
// The main push has already succeeded, so don't fail the whole command.
|
|
163
|
+
// Surface the issue clearly so the user knows to follow up.
|
|
164
|
+
this.log('');
|
|
165
|
+
this.log(`Warning: could not write the workspace pin to quonfig.json: ${String(error)}`);
|
|
166
|
+
this.log('The workspace is connected, but you should re-run `qfg pull` to backfill the pin.');
|
|
167
|
+
}
|
|
138
168
|
this.log(`\nBootstrap complete.`);
|
|
139
169
|
this.log(`Workspace "${workspaceName}" is now connected to your local directory.`);
|
|
140
170
|
this.log(`\nTo keep it in sync locally, run:`);
|
|
@@ -145,6 +175,32 @@ export default class WorkspaceBootstrap extends BaseCommand {
|
|
|
145
175
|
workspaceId,
|
|
146
176
|
};
|
|
147
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Stage, commit, and push the `quonfig.json` pin. Runs after the main
|
|
180
|
+
* bootstrap push so the pin lands as a committed+pushed change rather than
|
|
181
|
+
* an uncommitted local edit. We stage only `quonfig.json` (not `-A`) so we
|
|
182
|
+
* don't accidentally sweep in unrelated user edits.
|
|
183
|
+
*/
|
|
184
|
+
async commitAndPushPin(dir, slug, force) {
|
|
185
|
+
const { execFile: execFileCb } = await import('node:child_process');
|
|
186
|
+
const util = await import('node:util');
|
|
187
|
+
const execFile = util.promisify(execFileCb);
|
|
188
|
+
// Stage just quonfig.json so we don't accidentally commit other dirty files.
|
|
189
|
+
await execFile('git', ['-C', dir, 'add', 'quonfig.json']);
|
|
190
|
+
// If `git add` produced no staged change (e.g. content matched an earlier
|
|
191
|
+
// version on disk), `git commit` would fail. Check the index first.
|
|
192
|
+
const { stdout: diffStat } = await execFile('git', ['-C', dir, 'diff', '--cached', '--name-only']);
|
|
193
|
+
if (!diffStat.trim()) {
|
|
194
|
+
this.verboseLog('WorkspaceBootstrap', 'quonfig.json pin matches HEAD; no commit needed.');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
await execFile('git', ['-C', dir, 'commit', '-m', `chore: pin quonfig.json to workspace "${slug}"`]);
|
|
198
|
+
// Push the new commit using the same force semantics as the main push.
|
|
199
|
+
const pushArgs = ['-C', dir, 'push', 'origin', 'main'];
|
|
200
|
+
pushArgs.push(force ? '--force' : '--force-with-lease');
|
|
201
|
+
await execFile('git', pushArgs);
|
|
202
|
+
this.log(`Pushed pin commit.`);
|
|
203
|
+
}
|
|
148
204
|
async remoteHasCommits(repoUrl) {
|
|
149
205
|
const { execFile: execFileCb } = await import('node:child_process');
|
|
150
206
|
const util = await import('node:util');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ImportState } from './import-state.js';
|
|
2
2
|
import { type MigrationReportData } from './migration-report.js';
|
|
3
|
-
import { type PushIdentity } from '
|
|
3
|
+
import { type PushIdentity } from '../util/clone-and-stack-push.js';
|
|
4
4
|
import type { LegacyChange, MigrationSource } from './source.js';
|
|
5
5
|
export interface ApplyLocalMigrationOptions {
|
|
6
6
|
/** Commit author. Defaults to the migrator identity. */
|
|
@@ -4,7 +4,7 @@ import * as path from 'node:path';
|
|
|
4
4
|
import * as util from 'node:util';
|
|
5
5
|
import { removeQfFromGitignore, writeImportState } from './import-state.js';
|
|
6
6
|
import { writeMigrationReport } from './migration-report.js';
|
|
7
|
-
import { MIGRATOR_IDENTITY } from '
|
|
7
|
+
import { MIGRATOR_IDENTITY } from '../util/clone-and-stack-push.js';
|
|
8
8
|
const execFile = util.promisify(execFileCb);
|
|
9
9
|
const isGitRepo = async (dir) => {
|
|
10
10
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ImportState } from './import-state.js';
|
|
2
2
|
import { type MigrationReportData } from './migration-report.js';
|
|
3
|
-
import { type CloneAndStackPushResult } from '
|
|
3
|
+
import { type CloneAndStackPushResult } from '../util/clone-and-stack-push.js';
|
|
4
4
|
import type { LegacyChange, MigrationSource } from './source.js';
|
|
5
5
|
export interface PushMigrationToCloudOptions {
|
|
6
6
|
branch?: string;
|
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { removeQfFromGitignore, writeImportState } from './import-state.js';
|
|
4
4
|
import { writeMigrationReport } from './migration-report.js';
|
|
5
|
-
import { cloneAndStackPush } from '
|
|
5
|
+
import { cloneAndStackPush } from '../util/clone-and-stack-push.js';
|
|
6
6
|
const writeQuonfigFiles = (dir, changes, source) => {
|
|
7
7
|
for (const change of changes) {
|
|
8
8
|
const files = source.translate(change);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bare-path diff helper for `qfg push`.
|
|
3
|
+
*
|
|
4
|
+
* When the user's local dir is NOT a clone of the cloud repo (no `.git/`, or
|
|
5
|
+
* a mismatched origin) we still need to show a Guard 3 diff summary BEFORE
|
|
6
|
+
* asking them to confirm the push. The clone-path implementation uses
|
|
7
|
+
* `git log origin/main..HEAD` — that doesn't work here because there is no
|
|
8
|
+
* origin in the local dir.
|
|
9
|
+
*
|
|
10
|
+
* Strategy: probe-clone the cloud repo into an OS tmpdir, then walk both
|
|
11
|
+
* trees file-by-file and classify each as added / modified / deleted.
|
|
12
|
+
*
|
|
13
|
+
* The caller owns cleanup of `scratchDir`. The real caller (buildRealDeps in
|
|
14
|
+
* `commands/push.ts`) stashes the scratch clone path on a closure so it can
|
|
15
|
+
* delete it in a finally-block after runPush resolves — matching the bare
|
|
16
|
+
* path's existing scratch-clone lifecycle in `run-push.ts`.
|
|
17
|
+
*
|
|
18
|
+
* Intentional non-goals:
|
|
19
|
+
* - No token handling. Caller passes a fully-authenticated `remoteUrl`.
|
|
20
|
+
* - No filtering to "known" workspace dirs (configs/, feature-flags/, ...).
|
|
21
|
+
* We exclude dotfiles/dotdirs (`.git`, `.DS_Store`, `.quonfig-*`) so the
|
|
22
|
+
* scratch clone's own `.git/` does not leak into the diff, but otherwise
|
|
23
|
+
* diff whatever is on disk. This keeps the diff surface consistent with
|
|
24
|
+
* `copyDirMirror` in `commands/push.ts`, which mirrors every non-dotfile
|
|
25
|
+
* into the real push clone.
|
|
26
|
+
*/
|
|
27
|
+
import { FileDelta } from './diff-summary.js';
|
|
28
|
+
export interface BarePathDiffResult {
|
|
29
|
+
deltas: FileDelta[];
|
|
30
|
+
/** Tmp dir holding the probe clone. Caller must rm -rf in a finally block. */
|
|
31
|
+
scratchDir: string;
|
|
32
|
+
/** Total file count in the scratch clone (under the same prefix rules as `deltas`). */
|
|
33
|
+
totalRemoteFiles: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Clone `remoteUrl` into an OS tmp dir, then diff the working tree of `localDir`
|
|
37
|
+
* against the scratch clone's working tree. Returns `FileDelta[]` plus the
|
|
38
|
+
* scratch clone's file count.
|
|
39
|
+
*/
|
|
40
|
+
export declare function computeBarePathDiff(localDir: string, remoteUrl: string): Promise<BarePathDiffResult>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bare-path diff helper for `qfg push`.
|
|
3
|
+
*
|
|
4
|
+
* When the user's local dir is NOT a clone of the cloud repo (no `.git/`, or
|
|
5
|
+
* a mismatched origin) we still need to show a Guard 3 diff summary BEFORE
|
|
6
|
+
* asking them to confirm the push. The clone-path implementation uses
|
|
7
|
+
* `git log origin/main..HEAD` — that doesn't work here because there is no
|
|
8
|
+
* origin in the local dir.
|
|
9
|
+
*
|
|
10
|
+
* Strategy: probe-clone the cloud repo into an OS tmpdir, then walk both
|
|
11
|
+
* trees file-by-file and classify each as added / modified / deleted.
|
|
12
|
+
*
|
|
13
|
+
* The caller owns cleanup of `scratchDir`. The real caller (buildRealDeps in
|
|
14
|
+
* `commands/push.ts`) stashes the scratch clone path on a closure so it can
|
|
15
|
+
* delete it in a finally-block after runPush resolves — matching the bare
|
|
16
|
+
* path's existing scratch-clone lifecycle in `run-push.ts`.
|
|
17
|
+
*
|
|
18
|
+
* Intentional non-goals:
|
|
19
|
+
* - No token handling. Caller passes a fully-authenticated `remoteUrl`.
|
|
20
|
+
* - No filtering to "known" workspace dirs (configs/, feature-flags/, ...).
|
|
21
|
+
* We exclude dotfiles/dotdirs (`.git`, `.DS_Store`, `.quonfig-*`) so the
|
|
22
|
+
* scratch clone's own `.git/` does not leak into the diff, but otherwise
|
|
23
|
+
* diff whatever is on disk. This keeps the diff surface consistent with
|
|
24
|
+
* `copyDirMirror` in `commands/push.ts`, which mirrors every non-dotfile
|
|
25
|
+
* into the real push clone.
|
|
26
|
+
*/
|
|
27
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import * as fs from 'node:fs';
|
|
30
|
+
import * as os from 'node:os';
|
|
31
|
+
import * as path from 'node:path';
|
|
32
|
+
import * as util from 'node:util';
|
|
33
|
+
const execFile = util.promisify(execFileCb);
|
|
34
|
+
/**
|
|
35
|
+
* Clone `remoteUrl` into an OS tmp dir, then diff the working tree of `localDir`
|
|
36
|
+
* against the scratch clone's working tree. Returns `FileDelta[]` plus the
|
|
37
|
+
* scratch clone's file count.
|
|
38
|
+
*/
|
|
39
|
+
export async function computeBarePathDiff(localDir, remoteUrl) {
|
|
40
|
+
const scratchDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'qfg-push-probe-'));
|
|
41
|
+
try {
|
|
42
|
+
// --depth 1 would miss information needed for later identity checks, but
|
|
43
|
+
// for the diff we only need the tip — shallow is fine and a lot faster on
|
|
44
|
+
// workspaces with 2 years of history.
|
|
45
|
+
await execFile('git', ['clone', '--branch', 'main', '--depth', '1', remoteUrl, scratchDir]);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// Clean up before re-throwing so we never leak a scratch dir on failure.
|
|
49
|
+
try {
|
|
50
|
+
fs.rmSync(scratchDir, { force: true, recursive: true });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* ignore */
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
const localFiles = collectFilesWithHash(localDir);
|
|
58
|
+
const remoteFiles = collectFilesWithHash(scratchDir);
|
|
59
|
+
const deltas = [];
|
|
60
|
+
for (const [rel, localHash] of localFiles) {
|
|
61
|
+
const remoteHash = remoteFiles.get(rel);
|
|
62
|
+
if (remoteHash === undefined) {
|
|
63
|
+
deltas.push({ kind: 'added', path: rel });
|
|
64
|
+
}
|
|
65
|
+
else if (remoteHash !== localHash) {
|
|
66
|
+
deltas.push({ kind: 'modified', path: rel });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const rel of remoteFiles.keys()) {
|
|
70
|
+
if (!localFiles.has(rel)) {
|
|
71
|
+
deltas.push({ kind: 'deleted', path: rel });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { deltas, scratchDir, totalRemoteFiles: remoteFiles.size };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Walk `root` and return a map of relative-path -> content-hash for every
|
|
78
|
+
* non-dotfile file under it.
|
|
79
|
+
*
|
|
80
|
+
* We skip any entry whose name starts with a `.`, which covers `.git/`,
|
|
81
|
+
* `.DS_Store`, and the transient `.quonfig-push-clone-*` scratch dirs that
|
|
82
|
+
* `run-push.ts` creates next to the local workspace. Keeping the rule simple
|
|
83
|
+
* and symmetric on both sides prevents one-off false positives.
|
|
84
|
+
*/
|
|
85
|
+
function collectFilesWithHash(root) {
|
|
86
|
+
const out = new Map();
|
|
87
|
+
if (!fs.existsSync(root))
|
|
88
|
+
return out;
|
|
89
|
+
const walk = (dir, prefix) => {
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (entry.name.startsWith('.'))
|
|
99
|
+
continue;
|
|
100
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
101
|
+
const full = path.join(dir, entry.name);
|
|
102
|
+
if (entry.isDirectory()) {
|
|
103
|
+
walk(full, rel);
|
|
104
|
+
}
|
|
105
|
+
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
106
|
+
try {
|
|
107
|
+
const buf = fs.readFileSync(full);
|
|
108
|
+
out.set(rel, hashBytes(buf));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* ignore unreadable files — they won't appear on either side */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
walk(root, '');
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Content hash. We don't need cryptographic strength — just a stable
|
|
121
|
+
* fingerprint so we can say "file A on local matches file A on remote". The
|
|
122
|
+
* contents are small JSON in practice, so sha1 is plenty fast.
|
|
123
|
+
*/
|
|
124
|
+
function hashBytes(buf) {
|
|
125
|
+
return createHash('sha1').update(buf).digest('hex');
|
|
126
|
+
}
|