@quonfig/cli 0.0.14 → 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 +93 -37
- 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/commands/workspace/switch.d.ts +3 -0
- package/dist/commands/workspace/switch.js +25 -3
- package/dist/commands/workspace.d.ts +1 -0
- package/dist/commands/workspace.js +55 -0
- 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/get-client.js +1 -1
- 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 +113 -3
- 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,9 @@
|
|
|
1
1
|
import type { JsonObj } from '../../result.js';
|
|
2
2
|
import { BaseCommand } from '../../index.js';
|
|
3
3
|
export default class WorkspaceSwitch extends BaseCommand {
|
|
4
|
+
static args: {
|
|
5
|
+
slug: import("@oclif/core/lib/interfaces/parser.js").Arg<string | undefined, Record<string, unknown>>;
|
|
6
|
+
};
|
|
4
7
|
static description: string;
|
|
5
8
|
static examples: string[];
|
|
6
9
|
run(): Promise<JsonObj | void>;
|
|
@@ -1,12 +1,24 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
1
2
|
import { select } from '@inquirer/prompts';
|
|
2
3
|
import { BaseCommand } from '../../index.js';
|
|
3
4
|
import { getApiUrl } from '../../util/domain-urls.js';
|
|
4
5
|
import { getValidAccessToken } from '../../util/get-valid-token.js';
|
|
5
6
|
import { loadAuthConfig, saveAuthConfig } from '../../util/token-storage.js';
|
|
7
|
+
const UUID_PATTERN = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
|
|
6
8
|
export default class WorkspaceSwitch extends BaseCommand {
|
|
9
|
+
static args = {
|
|
10
|
+
slug: Args.string({
|
|
11
|
+
description: 'Workspace slug (or UUID) to switch to. Omit for an interactive picker.',
|
|
12
|
+
required: false,
|
|
13
|
+
}),
|
|
14
|
+
};
|
|
7
15
|
static description = 'Switch to a different workspace';
|
|
8
|
-
static examples = [
|
|
16
|
+
static examples = [
|
|
17
|
+
'<%= config.bin %> <%= command.id %>',
|
|
18
|
+
'<%= config.bin %> <%= command.id %> my-workspace-slug',
|
|
19
|
+
];
|
|
9
20
|
async run() {
|
|
21
|
+
const { args } = await this.parse(WorkspaceSwitch);
|
|
10
22
|
// Get a valid token — refresh silently if needed, error if not logged in
|
|
11
23
|
let accessToken;
|
|
12
24
|
try {
|
|
@@ -44,9 +56,19 @@ export default class WorkspaceSwitch extends BaseCommand {
|
|
|
44
56
|
if (candidates.length === 0) {
|
|
45
57
|
return this.err('No workspaces found for your account.');
|
|
46
58
|
}
|
|
47
|
-
// Pick workspace
|
|
59
|
+
// Pick workspace: positional arg → interactive picker → auto-select if only one
|
|
48
60
|
let match;
|
|
49
|
-
if (
|
|
61
|
+
if (args.slug) {
|
|
62
|
+
const found = UUID_PATTERN.test(args.slug)
|
|
63
|
+
? candidates.find((w) => w.workspaceId === args.slug)
|
|
64
|
+
: candidates.find((w) => w.workspaceSlug === args.slug);
|
|
65
|
+
if (!found) {
|
|
66
|
+
const available = candidates.map((w) => w.workspaceSlug).join(', ') || '(none)';
|
|
67
|
+
return this.err(`Workspace "${args.slug}" not found. Available: ${available}.`);
|
|
68
|
+
}
|
|
69
|
+
match = found;
|
|
70
|
+
}
|
|
71
|
+
else if (candidates.length === 1) {
|
|
50
72
|
match = candidates[0];
|
|
51
73
|
this.log(`Only one workspace available: ${match.organizationName} / ${match.workspaceSlug}`);
|
|
52
74
|
}
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { BaseCommand } from '../index.js';
|
|
2
|
+
import { getApiUrl } from '../util/domain-urls.js';
|
|
3
|
+
import { getValidAccessToken } from '../util/get-valid-token.js';
|
|
2
4
|
import { getActiveProfile, loadAuthConfig } from '../util/token-storage.js';
|
|
5
|
+
const UUID_PATTERN = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
|
|
3
6
|
export default class Workspace extends BaseCommand {
|
|
4
7
|
static description = 'Show the current workspace';
|
|
5
8
|
static examples = ['<%= config.bin %> <%= command.id %>'];
|
|
6
9
|
async run() {
|
|
10
|
+
// API-key mode: resolve the workspace from QUONFIG_WORKSPACE via the server
|
|
11
|
+
// so we show the workspace the key is actually scoped to, not whatever
|
|
12
|
+
// the local OAuth config happens to point at.
|
|
13
|
+
if (process.env.QUONFIG_API_KEY) {
|
|
14
|
+
return this.runApiKeyMode();
|
|
15
|
+
}
|
|
7
16
|
const authConfig = await loadAuthConfig();
|
|
8
17
|
if (!authConfig || Object.keys(authConfig.profiles).length === 0) {
|
|
9
18
|
return this.err('Not logged in. Run `qfg login` first.');
|
|
@@ -26,4 +35,50 @@ export default class Workspace extends BaseCommand {
|
|
|
26
35
|
workspaceSlug,
|
|
27
36
|
};
|
|
28
37
|
}
|
|
38
|
+
async runApiKeyMode() {
|
|
39
|
+
const override = process.env.QUONFIG_WORKSPACE;
|
|
40
|
+
if (!override) {
|
|
41
|
+
return this.err('QUONFIG_API_KEY is set but QUONFIG_WORKSPACE is not. Set QUONFIG_WORKSPACE=<workspace-slug> (UUIDs also work).');
|
|
42
|
+
}
|
|
43
|
+
// getValidAccessToken short-circuits when QUONFIG_API_KEY is set.
|
|
44
|
+
let jwt;
|
|
45
|
+
try {
|
|
46
|
+
jwt = await getValidAccessToken();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
return this.err(error instanceof Error ? error.message : String(error));
|
|
50
|
+
}
|
|
51
|
+
let entries = [];
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`${getApiUrl()}/api/v1/userWorkspaces/list`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify({ json: {} }),
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
return this.err(`Failed to resolve workspace (HTTP ${res.status}). Check that QUONFIG_API_KEY is valid.`);
|
|
60
|
+
}
|
|
61
|
+
const body = (await res.json());
|
|
62
|
+
entries = body.json ?? body ?? [];
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return this.err(`Failed to resolve workspace "${override}": ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
const match = UUID_PATTERN.test(override)
|
|
68
|
+
? entries.find((w) => w.workspaceId === override)
|
|
69
|
+
: entries.find((w) => w.workspaceSlug === override);
|
|
70
|
+
if (!match) {
|
|
71
|
+
const available = entries.map((w) => w.workspaceSlug).join(', ') || '(none)';
|
|
72
|
+
return this.err(`Workspace "${override}" not accessible with this API key. Available: ${available}.`);
|
|
73
|
+
}
|
|
74
|
+
const orgLine = match.organizationName ? `${match.organizationName} / ` : '';
|
|
75
|
+
this.log(`Workspace: ${orgLine}${match.workspaceSlug} (API key mode)`);
|
|
76
|
+
this.log(`ID: ${match.workspaceId}`);
|
|
77
|
+
this.log(`Source: QUONFIG_API_KEY + QUONFIG_WORKSPACE=${override}`);
|
|
78
|
+
return {
|
|
79
|
+
organizationName: match.organizationName,
|
|
80
|
+
workspace: match.workspaceId,
|
|
81
|
+
workspaceSlug: match.workspaceSlug,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
29
84
|
}
|
|
@@ -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);
|