@quonfig/cli 0.0.73 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,16 @@ 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, tryParseWorkspacePin, writeWorkspaceSlug } from '../../util/quonfig-json.js';
7
+ import { bootstrapPinMismatch, describeBootstrapTarget } from '../../util/bootstrap-target.js';
8
+ import { readWorkspaceSlug } from '../../util/quonfig-json.js';
8
9
  import { resolveWorkspaceUuid } from '../../util/resolve-workspace.js';
9
- import { isGitRepo, hasAtLeastOneCommit, gitSetRemote, gitPushForceLease, gitPushForce, getRemoteUrl, displayUrl, runGit, } from '../../util/git-ops.js';
10
+ import { isGitRepo, hasAtLeastOneCommit, gitSetRemote, gitFetch, workspaceDocumentsAtRef, rebaseOntoOriginAndPush, getRemoteUrl, getOriginMainSha, displayUrl, } from '../../util/git-ops.js';
11
+ /**
12
+ * How the customer points their clone at the workspace after bootstrap.
13
+ * `pre-bootstrap` keeps their original history; the reset is safe because the
14
+ * pushed tree was proven equal to their tree before the push.
15
+ */
16
+ const LOCAL_RESET_RECIPE = 'git branch pre-bootstrap && git fetch origin && git reset --hard origin/main';
10
17
  export default class WorkspaceBootstrap extends BaseCommand {
11
18
  static description = "Push a local git repo to Gitea as this workspace's config repository";
12
19
  static examples = [
@@ -21,7 +28,7 @@ export default class WorkspaceBootstrap extends BaseCommand {
21
28
  }),
22
29
  force: Flags.boolean({
23
30
  default: false,
24
- description: 'Force push even if remote already has commits',
31
+ description: 'Accepted and ignored (no-op). Bootstrap never rewrites the workspace history.',
25
32
  }),
26
33
  'skip-validate': Flags.boolean({
27
34
  default: false,
@@ -33,13 +40,34 @@ export default class WorkspaceBootstrap extends BaseCommand {
33
40
  // Resolve target directory
34
41
  const dir = flags.dir || process.env.QUONFIG_DIR || process.cwd();
35
42
  const resolvedDir = path.resolve(dir);
36
- const { workspaceId, orgSlug } = await resolveWorkspaceUuid(this);
37
- // The display name is best-effort; we still want it for the confirmation
38
- // prompt. Fall back through saved profile → workspaceId UUID.
43
+ // `--force` is kept so pinned scripts don't die on an unknown flag, but a
44
+ // workspace's `main` is append-only: nothing here ever rewrites it.
45
+ if (flags.force) {
46
+ this.log('Note: --force is accepted for compatibility and ignored — bootstrap never rewrites the workspace.\n');
47
+ }
48
+ // The target is resolved like push/pull/sync resolve theirs: QUONFIG_WORKSPACE,
49
+ // then the directory's quonfig.json pin, then the active profile.
50
+ const { workspaceId, orgSlug } = await resolveWorkspaceUuid(this, undefined, resolvedDir);
51
+ // The pin is read again here for the prompt and for the mismatch guard
52
+ // below. A legacy bare-slug pin reads as "no pin", as it does for push.
53
+ let pin;
54
+ try {
55
+ pin = await readWorkspaceSlug(resolvedDir);
56
+ }
57
+ catch {
58
+ pin = undefined;
59
+ }
60
+ // The prompt names whichever source chose the target; the profile only
61
+ // when it IS the resolved workspace (qfg-8p8i).
39
62
  const authConfig = await loadAuthConfig();
40
63
  const activeProfile = getActiveProfile();
41
64
  const profile = authConfig?.profiles[activeProfile] || authConfig?.profiles[authConfig?.defaultProfile || 'default'];
42
- const workspaceName = profile?.workspaceSlug || profile?.workspaceName || workspaceId;
65
+ const workspaceName = describeBootstrapTarget({
66
+ envOverride: process.env.QUONFIG_WORKSPACE,
67
+ pin,
68
+ profile,
69
+ workspaceId,
70
+ });
43
71
  this.verboseLog('WorkspaceBootstrap', { workspaceId, orgSlug, dir: resolvedDir });
44
72
  // Validate: must be a git repo with at least one commit
45
73
  const isRepo = await isGitRepo(resolvedDir);
@@ -101,16 +129,11 @@ export default class WorkspaceBootstrap extends BaseCommand {
101
129
  }
102
130
  const { repoUrl, workspaceSlug: backendSlug } = tokenData;
103
131
  this.verboseLog('WorkspaceBootstrap', { repoUrl: displayUrl(repoUrl), backendSlug });
104
- // Idempotency: check if remote already has commits
105
- if (!flags.force) {
106
- const remoteHasCommits = await this.remoteHasCommits(repoUrl);
107
- if (remoteHasCommits) {
108
- this.log(`\nThe remote repository already has commits.`);
109
- this.log(`Run \`qfg pull --dir ${resolvedDir}\` to sync, then re-run bootstrap.`);
110
- this.log(`Or run with --force to overwrite the remote.\n`);
111
- return this.err('Remote is not empty.');
112
- }
113
- }
132
+ // Guard, as `qfg push` has: a directory pinned to one workspace is not
133
+ // pushed to another. Checked before the remote is touched.
134
+ const mismatch = bootstrapPinMismatch(pin, backendSlug);
135
+ if (mismatch)
136
+ return this.err(mismatch);
114
137
  // Set remote
115
138
  if (existingRemote) {
116
139
  this.log(`Updating remote origin...`);
@@ -119,96 +142,65 @@ export default class WorkspaceBootstrap extends BaseCommand {
119
142
  this.log(`Setting remote origin...`);
120
143
  }
121
144
  await gitSetRemote(resolvedDir, repoUrl);
122
- // Push
145
+ await gitFetch(resolvedDir);
146
+ // Every workspace repo is provisioned with `main`; no `main` means the
147
+ // workspace is half-created, and the pre-receive hook rejects creating it
148
+ // by push (plan 5.6 item 1).
149
+ if ((await getOriginMainSha(resolvedDir)) === undefined) {
150
+ return this.err('The workspace repository is not provisioned (it has no `main` branch).\nFinish creating the workspace in the Quonfig app, then run bootstrap again.');
151
+ }
152
+ // Bootstrap is for a FRESH workspace only. "Fresh" is "holds no documents",
153
+ // NOT "has no commits": provisioning seeds README.md and quonfig.json, so
154
+ // every real workspace arrives with two commits (plan
155
+ // 2026-09-17-tree-derived-cache.md 5.6, 13.2 risk 5).
156
+ const documents = await workspaceDocumentsAtRef(resolvedDir, 'origin/main');
157
+ if (documents.length > 0) {
158
+ const sample = documents.slice(0, 3).join(', ') + (documents.length > 3 ? ', ...' : '');
159
+ this.log(`\nThis workspace already holds ${documents.length} document(s): ${sample}`);
160
+ this.log(`Bootstrap lands your local history UNDER what is already there, so it is for fresh workspaces only.`);
161
+ this.log(`To send local changes to a workspace that is already in use, run:`);
162
+ this.log(` qfg push --dir ${resolvedDir}\n`);
163
+ return this.err('Workspace is not empty.');
164
+ }
165
+ // Push. The history is replayed onto the workspace's own head on a
166
+ // temporary worktree and pushed plainly: `main` is append-only, and a
167
+ // force-push silently wedges config delivery (plan 5.6).
123
168
  this.log('Pushing to Gitea...');
169
+ let pushResult;
124
170
  try {
125
- if (flags.force) {
126
- await gitPushForce(resolvedDir);
127
- }
128
- else {
129
- await gitPushForceLease(resolvedDir);
130
- }
171
+ // Validation runs again against the tree that is actually pushed (which
172
+ // carries the WORKSPACE's quonfig.json, not the local one), so content
173
+ // the server would reject fails here.
174
+ pushResult = await rebaseOntoOriginAndPush(resolvedDir, { validate: !flags['skip-validate'] });
131
175
  }
132
176
  catch (error) {
133
177
  return this.err(`Push failed: ${String(error)}`);
134
178
  }
135
- // Write the workspace pin into `quonfig.json` (Guard 1 in
136
- // project/plans/cli-git-sync.md). If the pin already exists and disagrees,
137
- // we keep what is there — bootstrap is for fresh cases, not re-pinning.
138
- // If we do write it, commit and push so the bootstrap leaves no
139
- // uncommitted changes behind.
140
- //
141
- // The pin is stored as `<org-slug>/<workspace-slug>`. The backend's
142
- // `workspaceSlug` is just the workspace component today; until the
143
- // server returns the slash form, we skip the write rather than
144
- // emitting a bare slug.
145
- const backendPin = tryParseWorkspacePin(backendSlug);
146
- try {
147
- const existingPin = await readWorkspaceSlug(resolvedDir);
148
- const existingFormatted = existingPin ? `${existingPin.orgSlug}/${existingPin.workspaceSlug}` : undefined;
149
- if (!backendPin) {
150
- this.verboseLog('WorkspaceBootstrap', `Backend workspaceSlug "${backendSlug}" is not in <org>/<ws> form; skipping pin write.`);
151
- }
152
- else if (existingPin &&
153
- (existingPin.orgSlug !== backendPin.orgSlug || existingPin.workspaceSlug !== backendPin.workspaceSlug)) {
154
- const backendFormatted = `${backendPin.orgSlug}/${backendPin.workspaceSlug}`;
155
- this.log('');
156
- this.log(`Warning: quonfig.json already pins workspace "${existingFormatted}", but the backend says this workspace is "${backendFormatted}".`);
157
- this.log('Leaving the existing pin in place. If this is wrong, edit quonfig.json manually and re-run.');
158
- }
159
- else if (existingPin) {
160
- this.verboseLog('WorkspaceBootstrap', `quonfig.json already pinned to ${existingFormatted}; no-op.`);
161
- }
162
- else {
163
- const backendFormatted = `${backendPin.orgSlug}/${backendPin.workspaceSlug}`;
164
- this.log('');
165
- this.log(`Pinning quonfig.json to workspace "${backendFormatted}"...`);
166
- await writeWorkspaceSlug(resolvedDir, backendPin);
167
- await this.commitAndPushPin(resolvedDir, backendFormatted, flags.force);
168
- }
169
- }
170
- catch (error) {
171
- // The main push has already succeeded, so don't fail the whole command.
172
- // Surface the issue clearly so the user knows to follow up.
173
- this.log('');
174
- this.log(`Warning: could not write the workspace pin to quonfig.json: ${String(error)}`);
175
- this.log('The workspace is connected, but you should re-run `qfg pull` to backfill the pin.');
179
+ this.log(`Landed ${pushResult.commitsRebased} commit(s) on the workspace's history.`);
180
+ if (pushResult.reconcileSubject !== null) {
181
+ this.log(`Added one commit ("${pushResult.reconcileSubject}") so the workspace tree matches your local files.`);
176
182
  }
177
183
  this.log(`\nBootstrap complete.`);
178
- this.log(`Workspace "${workspaceName}" is now connected to your local directory.`);
179
- this.log(`\nTo keep it in sync locally, run:`);
180
- this.log(` qfg sync --watch --dir ${resolvedDir}`);
184
+ this.log(`Workspace "${workspaceName}" now holds the history from ${resolvedDir}.`);
185
+ // The customer's branch is never touched, on success or failure, so it
186
+ // still points at the pre-bootstrap history — which shares no commit with
187
+ // what was just pushed (the replayed commits have new ids). `qfg pull` and
188
+ // `qfg sync` both refuse that state ("diverge" / STALE_HEAD), so the only
189
+ // honest advice is a reset onto the workspace.
190
+ //
191
+ // If we ever move the local branch ourselves (option A: save
192
+ // refs/quonfig/pre-bootstrap and fast-forward `main` to pushedSha once
193
+ // tree equality is proven), this block is what it replaces.
194
+ this.log(`\nYour local branch was not moved. The workspace has the same content under new commit ids.`);
195
+ this.log(`To point this clone at the workspace, keeping your old history on a branch:`);
196
+ this.log(` ${LOCAL_RESET_RECIPE}`);
181
197
  return {
198
+ commitsRebased: pushResult.commitsRebased,
182
199
  dir: resolvedDir,
200
+ localResetRecipe: LOCAL_RESET_RECIPE,
201
+ reconciled: pushResult.reconcileSubject !== null,
183
202
  repoUrl: displayUrl(repoUrl),
184
203
  workspaceId,
185
204
  };
186
205
  }
187
- /**
188
- * Stage, commit, and push the `quonfig.json` pin. Runs after the main
189
- * bootstrap push so the pin lands as a committed+pushed change rather than
190
- * an uncommitted local edit. We stage only `quonfig.json` (not `-A`) so we
191
- * don't accidentally sweep in unrelated user edits.
192
- */
193
- async commitAndPushPin(dir, slug, force) {
194
- // Stage just quonfig.json so we don't accidentally commit other dirty files.
195
- await runGit(['-C', dir, 'add', 'quonfig.json']);
196
- // If `git add` produced no staged change (e.g. content matched an earlier
197
- // version on disk), `git commit` would fail. Check the index first.
198
- const { stdout: diffStat } = await runGit(['-C', dir, 'diff', '--cached', '--name-only']);
199
- if (!diffStat.trim()) {
200
- this.verboseLog('WorkspaceBootstrap', 'quonfig.json pin matches HEAD; no commit needed.');
201
- return;
202
- }
203
- await runGit(['-C', dir, 'commit', '-m', `chore: pin quonfig.json to workspace "${slug}"`]);
204
- // Push the new commit using the same force semantics as the main push.
205
- const pushArgs = ['-C', dir, 'push', 'origin', 'main'];
206
- pushArgs.push(force ? '--force' : '--force-with-lease');
207
- await runGit(pushArgs);
208
- this.log(`Pushed pin commit.`);
209
- }
210
- async remoteHasCommits(repoUrl) {
211
- const { stdout } = await runGit(['ls-remote', '--heads', repoUrl]);
212
- return stdout.trim().length > 0;
213
- }
214
206
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What `qfg workspace bootstrap` tells the user about the workspace it is
3
+ * about to push to, and the one check that stops it landing content in the
4
+ * wrong place.
5
+ *
6
+ * The target is resolved the same way `qfg push` / `qfg pull` / `qfg sync`
7
+ * resolve theirs: `QUONFIG_WORKSPACE`, then the directory's `quonfig.json`
8
+ * pin, then the active profile. The prompt must name whichever of those won;
9
+ * naming the profile when the env var chose another workspace is how a
10
+ * customer confirms a push to the wrong place (qfg-8p8i).
11
+ */
12
+ import type { WorkspacePin } from './quonfig-json.js';
13
+ export interface BootstrapProfile {
14
+ organizationSlug?: string;
15
+ workspace: string;
16
+ workspaceName?: string;
17
+ workspaceSlug?: string;
18
+ }
19
+ export interface BootstrapTargetInput {
20
+ /** `QUONFIG_WORKSPACE`, when set: it outranks everything else. */
21
+ envOverride: string | undefined;
22
+ /** The `--dir` directory's `quonfig.json` pin, when readable. */
23
+ pin: WorkspacePin | undefined;
24
+ /** The active profile, if any. Only trusted when it IS the resolved workspace. */
25
+ profile: BootstrapProfile | undefined;
26
+ /** What the resolver actually returned. */
27
+ workspaceId: string;
28
+ }
29
+ /**
30
+ * The name to show for the resolved workspace, from whichever source chose
31
+ * it. Falls back to the id rather than to a profile that points elsewhere.
32
+ */
33
+ export declare const describeBootstrapTarget: (input: BootstrapTargetInput) => string;
34
+ /**
35
+ * `qfg push`'s Guard 1 for bootstrap: a directory pinned to one workspace is
36
+ * not pushed to another. Compares the workspace component only, as push does
37
+ * (backend responses do not carry the org slug yet). Returns the refusal
38
+ * message, or nothing when there is no pin or it matches.
39
+ */
40
+ export declare const bootstrapPinMismatch: (pin: WorkspacePin | undefined, backendWorkspaceSlug: string) => string | undefined;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * What `qfg workspace bootstrap` tells the user about the workspace it is
3
+ * about to push to, and the one check that stops it landing content in the
4
+ * wrong place.
5
+ *
6
+ * The target is resolved the same way `qfg push` / `qfg pull` / `qfg sync`
7
+ * resolve theirs: `QUONFIG_WORKSPACE`, then the directory's `quonfig.json`
8
+ * pin, then the active profile. The prompt must name whichever of those won;
9
+ * naming the profile when the env var chose another workspace is how a
10
+ * customer confirms a push to the wrong place (qfg-8p8i).
11
+ */
12
+ /** The pin form of a workspace, `<org>/<workspace>`. */
13
+ const pinForm = (pin) => `${pin.orgSlug}/${pin.workspaceSlug}`;
14
+ /**
15
+ * The name to show for the resolved workspace, from whichever source chose
16
+ * it. Falls back to the id rather than to a profile that points elsewhere.
17
+ */
18
+ export const describeBootstrapTarget = (input) => {
19
+ if (input.envOverride)
20
+ return input.envOverride;
21
+ if (input.pin)
22
+ return pinForm(input.pin);
23
+ const { profile } = input;
24
+ if (profile && profile.workspace === input.workspaceId) {
25
+ if (profile.workspaceSlug) {
26
+ return profile.organizationSlug ? `${profile.organizationSlug}/${profile.workspaceSlug}` : profile.workspaceSlug;
27
+ }
28
+ if (profile.workspaceName)
29
+ return profile.workspaceName;
30
+ }
31
+ return input.workspaceId;
32
+ };
33
+ /**
34
+ * `qfg push`'s Guard 1 for bootstrap: a directory pinned to one workspace is
35
+ * not pushed to another. Compares the workspace component only, as push does
36
+ * (backend responses do not carry the org slug yet). Returns the refusal
37
+ * message, or nothing when there is no pin or it matches.
38
+ */
39
+ export const bootstrapPinMismatch = (pin, backendWorkspaceSlug) => {
40
+ if (!pin || pin.workspaceSlug === backendWorkspaceSlug)
41
+ return undefined;
42
+ return (`This directory's quonfig.json is pinned to workspace "${pinForm(pin)}", ` +
43
+ `but the target workspace is "${backendWorkspaceSlug}".\n` +
44
+ `Nothing was pushed. Unset QUONFIG_WORKSPACE to bootstrap the pinned workspace, ` +
45
+ `or change the pin in quonfig.json if "${backendWorkspaceSlug}" is really the target.`);
46
+ };
@@ -1,7 +1,4 @@
1
- export declare const MIGRATOR_IDENTITY: {
2
- readonly name: "quonfig migrator";
3
- readonly email: "migrator@quonfig.com";
4
- };
1
+ export { MIGRATOR_IDENTITY } from './git-ops.js';
5
2
  export interface PushIdentity {
6
3
  email: string;
7
4
  name: string;
@@ -1,10 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { runGit as runGitSafe, spawnGit } from './git-ops.js';
4
- export const MIGRATOR_IDENTITY = {
5
- name: 'quonfig migrator',
6
- email: 'migrator@quonfig.com',
7
- };
3
+ import { MIGRATOR_IDENTITY, runGit as runGitSafe, spawnGit } from './git-ops.js';
4
+ export { MIGRATOR_IDENTITY } from './git-ops.js';
8
5
  const redactToken = (s) => s.replaceAll(/:([^\s/@]+)@/g, ':***@');
9
6
  export class PushConflictError extends Error {
10
7
  constructor(message) {
@@ -142,8 +142,66 @@ export declare const gitMergeFfOnly: (dir: string) => Promise<string[]>;
142
142
  * Adds or updates the origin remote for a repo.
143
143
  */
144
144
  export declare const gitSetRemote: (dir: string, url: string) => Promise<void>;
145
- export declare const gitPushForceLease: (dir: string) => Promise<void>;
146
- export declare const gitPushForce: (dir: string) => Promise<void>;
145
+ /**
146
+ * Every file the validator owns at `ref` — config documents (`configs/`,
147
+ * `feature-flags/`, `segments/`, `log-levels/`) AND schemas (`schemas/`,
148
+ * `schemas-protected/`) — sorted.
149
+ *
150
+ * A workspace repo is FRESH exactly when this list is empty. It is NOT "the
151
+ * remote holds only the auto-init commit": provisioning commits `README.md`
152
+ * AND `quonfig.json`, so a commit-count test refuses every real workspace
153
+ * (plan project/plans/2026-09-17-tree-derived-cache.md 5.6, 13.2 risk 5).
154
+ * Schemas count because a UI-authored schema is content a local-wins rebase
155
+ * would silently overwrite, exactly like a flag.
156
+ *
157
+ * FAILS CLOSED: if the ref cannot be listed this throws. "I could not look"
158
+ * must never read as "the workspace is empty".
159
+ */
160
+ export declare const workspaceDocumentsAtRef: (dir: string, ref: string) => Promise<string[]>;
161
+ export interface RebaseOntoOriginResult {
162
+ /** How many of the customer's commits were replayed onto the remote head. */
163
+ commitsRebased: number;
164
+ /** The sha now at `origin/main`. */
165
+ pushedSha: string;
166
+ /** Subject of the extra commit that put the tree right, or null if none was needed. */
167
+ reconcileSubject: null | string;
168
+ }
169
+ /**
170
+ * The identity the CLI commits as when nothing better is known: `qfg push`
171
+ * and `qfg migrate` stack their commits under it, and bootstrap's replayed
172
+ * commits get it as COMMITTER when the machine has no git identity at all.
173
+ */
174
+ export declare const MIGRATOR_IDENTITY: {
175
+ readonly name: "quonfig migrator";
176
+ readonly email: "migrator@quonfig.com";
177
+ };
178
+ /**
179
+ * Land the local repo's history on `origin/main` WITHOUT ever force-pushing
180
+ * (plan 5.6 "Bootstrap keeps its history without force"; `main` is
181
+ * append-only, so a rewrite is not just forbidden, it silently wedges config
182
+ * delivery).
183
+ *
184
+ * `git fetch origin` is the caller's job. Then:
185
+ *
186
+ * 1. Replay the whole local history onto the remote head on a TEMPORARY
187
+ * worktree — never on the customer's branch. Whatever happens, their
188
+ * local refs and working tree are untouched.
189
+ * 2. Require the replayed tip's tree to equal the local tip's tree, ignoring
190
+ * files the workspace seeded that the local repo does not have. Rebasing
191
+ * linearises merges and `-X theirs` silently picks a side, so a
192
+ * hand-resolved merge conflict can otherwise ship the wrong content with
193
+ * exit 0 (13.2 risk 4).
194
+ * 3. If it differs, add ONE commit that sets the tree to the local tip's
195
+ * tree, keeping the seeded files and the workspace's `quonfig.json`. If
196
+ * that cannot be done cleanly, abort with nothing pushed.
197
+ * 4. Validate the tree that is ACTUALLY about to be pushed (unless
198
+ * `validate: false`), so content the workspace would reject fails here
199
+ * instead of at the server hook.
200
+ * 5. Plain `git push` of the result to `main`.
201
+ */
202
+ export declare const rebaseOntoOriginAndPush: (dir: string, opts?: {
203
+ validate?: boolean;
204
+ }) => Promise<RebaseOntoOriginResult>;
147
205
  export declare const hasAtLeastOneCommit: (dir: string) => Promise<boolean>;
148
206
  /**
149
207
  * Get the URL stripped of credentials for display purposes.