@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.
@@ -19,8 +19,6 @@
19
19
  * confirmation — that prompt is the destructive-change brake.
20
20
  */
21
21
  import * as fs from 'node:fs';
22
- import * as path from 'node:path';
23
- import { MIGRATOR_IDENTITY, cloneAndStackPush } from '../util/clone-and-stack-push.js';
24
22
  import { readWorkspaceSlug, writeWorkspaceSlug } from '../util/quonfig-json.js';
25
23
  import { checkIdentity } from './identity-check.js';
26
24
  import { confirmTypedSlug, confirmYesNo } from './confirm.js';
@@ -118,19 +116,18 @@ export async function runPush(input, deps) {
118
116
  // repo into a scratch dir inside `.quonfig-push-clone/` and stack a commit
119
117
  // that mirrors the local dir's contents on top.
120
118
  const isClonePath = hasGit && remoteOriginUrl !== undefined && sameRepo(remoteOriginUrl, backend.repoUrl);
119
+ const dispatchedAs = isClonePath ? 'clone-path' : 'bare-path';
120
+ // Clone path: align origin with the backend so the gitOps.fetch + diff
121
+ // walk authenticates against the read URL we minted. The bare path's
122
+ // probe-clone path bypasses local origin entirely.
121
123
  if (isClonePath) {
122
- return doClonePath(input, deps, backend, requiresTypedSlug, repoPinSlug === undefined, log);
124
+ await deps.gitOps.setRemoteOrigin(input.dir, backend.repoUrl);
125
+ log('Fetching from remote...');
126
+ await deps.gitOps.fetch(input.dir);
123
127
  }
124
- return doBarePath(input, deps, backend, requiresTypedSlug, repoPinSlug === undefined, log);
125
- }
126
- async function doClonePath(input, deps, backend, requiresTypedSlug, unpinned, log) {
127
- // Point origin at the authenticated URL so `git fetch` / `git push` work
128
- // without touching global credential helpers.
129
- await deps.gitOps.setRemoteOrigin(input.dir, backend.repoUrl);
130
- log('Fetching from remote...');
131
- await deps.gitOps.fetch(input.dir);
132
128
  const deltas = await deps.gitOps.diffHeadVsOrigin(input.dir);
133
129
  const totalFilesInRemote = await deps.gitOps.countFilesInRemote(input.dir);
130
+ const unpinned = repoPinSlug === undefined;
134
131
  const summary = summarizeDiff(deltas, { totalFilesInRemote, unpinned });
135
132
  log('');
136
133
  log(summary.renderText({
@@ -140,7 +137,6 @@ async function doClonePath(input, deps, backend, requiresTypedSlug, unpinned, lo
140
137
  localDir: input.dir,
141
138
  }));
142
139
  log('');
143
- // Short-circuit: literally nothing to push.
144
140
  if (summary.totals.filesTouched === 0) {
145
141
  return { kind: 'no-op', reason: 'Local tree matches remote HEAD. Nothing to push.' };
146
142
  }
@@ -155,141 +151,57 @@ async function doClonePath(input, deps, backend, requiresTypedSlug, unpinned, lo
155
151
  log('Aborted, nothing pushed.');
156
152
  return { kind: 'aborted', reason: 'user declined at confirm prompt' };
157
153
  }
158
- log('Pushing to origin/main...');
159
- try {
160
- await deps.gitOps.push(input.dir);
161
- }
162
- catch (error) {
163
- const msg = error instanceof Error ? error.message : String(error);
164
- if (/non-fast-forward|fetch first|rejected/i.test(msg)) {
165
- throw new PushFatalError('Remote has changes not in local — run `qfg pull` first, then re-run `qfg push`.', 'NON_FAST_FORWARD');
154
+ const baseMessage = input.message ?? `qfg push: ${summary.totals.filesTouched} file change(s)`;
155
+ const commitMessage = withPushedViaTrailer(baseMessage);
156
+ const serverFiles = deltas.map((d) => ({
157
+ path: d.path,
158
+ kind: toServerKind(d.kind),
159
+ ...(d.beforeJson === undefined ? {} : { beforeJson: d.beforeJson }),
160
+ ...(d.afterJson === undefined ? {} : { afterJson: d.afterJson }),
161
+ }));
162
+ log('Sending push to Quonfig cloud...');
163
+ const result = await deps.pushToServer({
164
+ workspaceId: backend.workspaceId,
165
+ files: serverFiles,
166
+ message: commitMessage,
167
+ });
168
+ if (result.kind === 'denied') {
169
+ errLog(`Push denied for ${result.denials.length} file(s):`);
170
+ for (const d of result.denials) {
171
+ errLog(` ${d.path}: missing permission ${d.requiredPermission}`);
166
172
  }
167
- throw new PushFatalError(`Push failed: ${msg}`, 'PUSH_FAILED');
173
+ throw new PushFatalError(`Push denied for ${result.denials.length} file(s). See errors above.`, 'PUSH_DENIED');
174
+ }
175
+ if (result.kind === 'conflict') {
176
+ throw new PushFatalError(`Remote moved while preparing this push (${result.message}). Run \`qfg pull\` and retry.`, 'CONFLICT');
177
+ }
178
+ if (result.kind === 'bad-request') {
179
+ throw new PushFatalError(`Push rejected by server: ${result.message}`, 'BAD_REQUEST');
168
180
  }
169
- // Offer to pin the workspace slug in quonfig.json if missing. Non-fatal.
170
181
  if (unpinned && !input.noPinWrite) {
171
182
  try {
172
183
  await writeWorkspaceSlug(input.dir, backend.workspaceSlug);
173
184
  log(`Wrote workspace = "${backend.workspaceSlug}" into quonfig.json`);
174
185
  }
175
186
  catch {
176
- // non-fatal; pin is advisory
187
+ /* non-fatal; pin is advisory */
177
188
  }
178
189
  }
179
- return { kind: 'pushed', dispatchedAs: 'clone-path' };
190
+ return { kind: 'pushed', dispatchedAs, commitSha: result.commitSha };
180
191
  }
181
- async function doBarePath(input, deps, backend, requiresTypedSlug, unpinned, log) {
182
- // Scratch clone location: sibling `.quonfig-push-clone-<timestamp>` so we
183
- // don't clobber or re-use anything from a previous run.
184
- const scratchDir = path.join(path.dirname(path.resolve(input.dir)), `.quonfig-push-clone-${Date.now()}`);
185
- // Local author for the migrated commit. Prefer the local git config in
186
- // `input.dir` so attribution matches what the user has set. If it is not
187
- // available, fall back to the migrator identity but tell the user — the plan
188
- // requires this be loud.
189
- let author = MIGRATOR_IDENTITY;
190
- try {
191
- const local = await deps.gitOps.getLocalAuthor(input.dir);
192
- if (local && local.name && local.email) {
193
- author = local;
194
- }
195
- else {
196
- log(`Warning: no local git user.name/user.email in ${input.dir}; commits will be attributed to ${MIGRATOR_IDENTITY.name} <${MIGRATOR_IDENTITY.email}>.`);
197
- }
198
- }
199
- catch {
200
- log(`Warning: could not read local git user identity; commits will be attributed to ${MIGRATOR_IDENTITY.name} <${MIGRATOR_IDENTITY.email}>.`);
201
- }
202
- // We need the deltas for the Guard 3 summary BEFORE we decide to commit and
203
- // push. To produce them we need the scratch clone in hand (so we can compare
204
- // file-by-file). We clone into the scratch dir, take the diff, show the
205
- // summary, confirm, then apply the delta via `cloneAndStackPush`.
206
- //
207
- // Rather than re-implement cloneAndStackPush's clone step here, we let it
208
- // own the clone-commit-push pipeline and pass a deltas callback that runs
209
- // AFTER the clone (it receives the localDir/scratchDir) but BEFORE the
210
- // commit. `cloneAndStackPush` only exposes an `applyDelta` hook (write files
211
- // then return), so we split the flow: first a probe clone to take the
212
- // diff, then the real clone-and-stack-push. This is simpler than rewiring
213
- // cloneAndStackPush's API.
214
- // Probe clone.
215
- await deps.gitOps.fetch(input.dir) // no-op for bare path if not a repo; deps.gitOps may short-circuit
216
- .catch(() => {
217
- // bare-path doesn't require a local fetch — swallow the error
218
- });
219
- // We don't have a probe clone helper; instead, ask the deps for a bare-path
220
- // delta computation. The test double will produce this from a fixture; the
221
- // real implementation walks both trees after ensuring a scratch clone exists.
222
- // This is provided by the command glue layer.
223
- const deltas = await deps.gitOps.diffHeadVsOrigin(input.dir);
224
- const totalFilesInRemote = await deps.gitOps.countFilesInRemote(input.dir);
225
- const summary = summarizeDiff(deltas, { totalFilesInRemote, unpinned });
226
- log('');
227
- log(summary.renderText({
228
- workspaceSlug: backend.workspaceSlug,
229
- repoUrl: stripAuth(backend.repoUrl),
230
- branch: 'main',
231
- localDir: input.dir,
232
- }));
233
- log('');
234
- if (summary.totals.filesTouched === 0) {
235
- return { kind: 'no-op', reason: 'Local tree matches remote HEAD. Nothing to push.' };
236
- }
237
- const ok = await decideConfirm({
238
- destructive: summary.isDestructive,
239
- requiresTypedSlug,
240
- yes: input.yes,
241
- workspaceSlug: backend.workspaceSlug,
242
- confirmIO: deps.confirmIO,
243
- });
244
- if (!ok) {
245
- log('Aborted, nothing pushed.');
246
- return { kind: 'aborted', reason: 'user declined at confirm prompt' };
247
- }
248
- const baseMessage = input.message ?? `qfg push: ${summary.totals.filesTouched} file change(s)`;
249
- const commitMessage = withPushedViaTrailer(baseMessage);
250
- log('Pushing via clone-and-stack...');
251
- try {
252
- const result = await cloneAndStackPush({
253
- remoteUrl: backend.repoUrl,
254
- localDir: scratchDir,
255
- author,
256
- commitMessage,
257
- async applyDelta(clonedDir) {
258
- // The caller promised deps.copyDirMirror copies `input.dir` into
259
- // `clonedDir`, skipping `.git/`. cloneAndStackPush will `git add --all`
260
- // after this returns.
261
- await deps.copyDirMirror(input.dir, clonedDir);
262
- },
263
- });
264
- if (unpinned && !input.noPinWrite) {
265
- try {
266
- await writeWorkspaceSlug(input.dir, backend.workspaceSlug);
267
- log(`Wrote workspace = "${backend.workspaceSlug}" into quonfig.json`);
268
- }
269
- catch {
270
- // non-fatal
271
- }
192
+ const toServerKind = (kind) => {
193
+ switch (kind) {
194
+ case 'added': {
195
+ return 'add';
272
196
  }
273
- return { kind: 'pushed', dispatchedAs: 'bare-path', commitSha: result.commitSha };
274
- }
275
- catch (error) {
276
- const msg = error instanceof Error ? error.message : String(error);
277
- if (/non-fast-forward|fetch first|rejected/i.test(msg)) {
278
- throw new PushFatalError('Remote has changes not in local — run `qfg pull` first, then re-run `qfg push`.', 'NON_FAST_FORWARD');
197
+ case 'deleted': {
198
+ return 'delete';
279
199
  }
280
- throw new PushFatalError(`Push failed: ${msg}`, 'PUSH_FAILED');
281
- }
282
- finally {
283
- // Best-effort cleanup of the scratch clone. Ignore failures so a cleanup
284
- // problem doesn't mask a real error.
285
- try {
286
- fs.rmSync(scratchDir, { recursive: true, force: true });
287
- }
288
- catch {
289
- /* ignore */
200
+ case 'modified': {
201
+ return 'modify';
290
202
  }
291
203
  }
292
- }
204
+ };
293
205
  /**
294
206
  * Map the guard outputs to the right prompt:
295
207
  * - Typed-slug (always, never skipped by --yes) when identity demanded it
package/dist/quonfig.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Quonfig } from '@quonfig/node';
2
+ import { getDeliveryUrl } from './util/domain-urls.js';
2
3
  let quonfig;
3
4
  const DEFAULT_CONTEXT_USER_ID_NAMESPACE = 'prefab-api-key';
4
5
  const DEFAULT_CONTEXT_USER_ID = 'user-id';
@@ -14,7 +15,7 @@ export const initQuonfig = async (_ctx, flagsOrDatafile) => {
14
15
  }
15
16
  sdkKey = flagsOrDatafile['sdk-key'];
16
17
  }
17
- const apiUrl = process.env.QUONFIG_API_URL || 'https://api.quonfig.com';
18
+ const apiUrl = getDeliveryUrl();
18
19
  quonfig = new Quonfig({
19
20
  sdkKey,
20
21
  collectEvaluationSummaries: false,
@@ -1,3 +1,4 @@
1
1
  export declare const getDomain: () => string;
2
2
  export declare const getApiUrl: (domain?: string) => string;
3
3
  export declare const getAppUrl: (domain?: string) => string;
4
+ export declare const getDeliveryUrl: (domain?: string) => string;
@@ -16,3 +16,10 @@ export const getAppUrl = (domain) => {
16
16
  const actualDomain = domain || getDomain();
17
17
  return `https://app.${actualDomain}`;
18
18
  };
19
+ export const getDeliveryUrl = (domain) => {
20
+ if (process.env.QUONFIG_API_URL) {
21
+ return process.env.QUONFIG_API_URL;
22
+ }
23
+ const actualDomain = domain || getDomain();
24
+ return `https://primary.${actualDomain}`;
25
+ };
@@ -1,9 +1,9 @@
1
1
  import * as fs from 'node:fs';
2
- import * as os from 'node:os';
3
2
  import * as path from 'node:path';
4
3
  import { getDomain } from './domain-urls.js';
4
+ import { getQuonfigConfigHome } from './quonfig-home.js';
5
5
  const BUFFER_MS = 5 * 60 * 1000; // 5 minutes
6
- const getQuonfigDir = () => path.join(os.homedir(), '.quonfig');
6
+ const getQuonfigDir = () => getQuonfigConfigHome();
7
7
  const getGiteaTokenFile = () => {
8
8
  const domain = getDomain();
9
9
  const filename = domain === 'quonfig.com' ? 'gitea-tokens.json' : `gitea-tokens-${domain.replaceAll('.', '-')}.json`;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Resolve the directory the CLI uses for tokens, profile config, and gitea
3
+ * tokens. Honors `QUONFIG_CONFIG_HOME` so tests (and anyone who wants an
4
+ * isolated config dir) can redirect away from `~/.quonfig/`.
5
+ */
6
+ export declare const getQuonfigConfigHome: () => string;
@@ -0,0 +1,8 @@
1
+ import * as os from 'node:os';
2
+ import * as path from 'node:path';
3
+ /**
4
+ * Resolve the directory the CLI uses for tokens, profile config, and gitea
5
+ * tokens. Honors `QUONFIG_CONFIG_HOME` so tests (and anyone who wants an
6
+ * isolated config dir) can redirect away from `~/.quonfig/`.
7
+ */
8
+ export const getQuonfigConfigHome = () => process.env.QUONFIG_CONFIG_HOME || path.join(os.homedir(), '.quonfig');
@@ -1,8 +1,8 @@
1
1
  import * as fs from 'node:fs';
2
- import * as os from 'node:os';
3
2
  import * as path from 'node:path';
4
3
  import { getDomain } from './domain-urls.js';
5
- const getQuonfigDir = (options) => options?.quonfigDir || path.join(os.homedir(), '.quonfig');
4
+ import { getQuonfigConfigHome } from './quonfig-home.js';
5
+ const getQuonfigDir = (options) => options?.quonfigDir || getQuonfigConfigHome();
6
6
  const getTokenFile = (options) => {
7
7
  const domain = getDomain();
8
8
  const filename = domain === 'quonfig.com' ? 'tokens.json' : `tokens-${domain.replaceAll('.', '-')}.json`;
@@ -118,6 +118,7 @@ const VariantSchema = z.object({
118
118
  value: ValueSchema,
119
119
  description: z.string().optional(),
120
120
  });
121
+ const AccessSchema = z.enum(['support', 'standard', 'protected-env', 'protected-all-envs']);
121
122
  const StoredConfigSchema = z
122
123
  .object({
123
124
  id: z.string().optional(),
@@ -129,8 +130,7 @@ const StoredConfigSchema = z
129
130
  description: z.string().optional(),
130
131
  sendToClientSdk: z.boolean().optional(),
131
132
  schemaKey: z.string().optional(),
132
- accessLevel: z.string().optional(),
133
- protection: z.string().optional(),
133
+ access: AccessSchema.optional(),
134
134
  default: z.object({
135
135
  rules: z.array(ConfigRuleSchema),
136
136
  }),
@@ -202,6 +202,93 @@
202
202
  "create.js"
203
203
  ]
204
204
  },
205
+ "delete": {
206
+ "aliases": [],
207
+ "args": {
208
+ "name": {
209
+ "description": "flag/config/log-level key to delete",
210
+ "name": "name",
211
+ "required": true
212
+ }
213
+ },
214
+ "description": "Delete a flag, config, or log-level from the workspace.\n\nDESTRUCTIVE — removes the underlying JSON file from the workspace git repo\nand commits with the actor's identity. The commit history preserves the\nprior content; this is a hard-delete on the file but a soft-delete on\nhistory.\n\nConfirmation is required:\n --yes skip the prompt (for scripts and CI)\n interactive (default) you'll be asked to type the key name back\n\nExamples\n qfg delete my.flag --yes\n qfg delete my.flag # interactive: type the key back to confirm\n qfg delete my.config --yes",
215
+ "examples": [
216
+ "<%= config.bin %> <%= command.id %> my.flag --yes",
217
+ "<%= config.bin %> <%= command.id %> my.config --yes"
218
+ ],
219
+ "flags": {
220
+ "json": {
221
+ "description": "Format output as json.",
222
+ "helpGroup": "GLOBAL",
223
+ "name": "json",
224
+ "allowNo": false,
225
+ "type": "boolean"
226
+ },
227
+ "interactive": {
228
+ "description": "Force interactive mode",
229
+ "helpGroup": "GLOBAL",
230
+ "name": "interactive",
231
+ "allowNo": true,
232
+ "type": "boolean"
233
+ },
234
+ "no-color": {
235
+ "description": "Do not colorize output",
236
+ "env": "NO_COLOR",
237
+ "helpGroup": "GLOBAL",
238
+ "name": "no-color",
239
+ "allowNo": false,
240
+ "type": "boolean"
241
+ },
242
+ "verbose": {
243
+ "description": "Verbose output",
244
+ "helpGroup": "GLOBAL",
245
+ "name": "verbose",
246
+ "allowNo": false,
247
+ "type": "boolean"
248
+ },
249
+ "workspace": {
250
+ "char": "w",
251
+ "description": "Workspace slug to use (overrides QUONFIG_WORKSPACE env var and saved default)",
252
+ "helpGroup": "GLOBAL",
253
+ "name": "workspace",
254
+ "required": false,
255
+ "hasDynamicHelp": false,
256
+ "multiple": false,
257
+ "type": "option"
258
+ },
259
+ "profile": {
260
+ "char": "p",
261
+ "description": "Deprecated: use --workspace instead",
262
+ "helpGroup": "GLOBAL",
263
+ "hidden": true,
264
+ "name": "profile",
265
+ "required": false,
266
+ "hasDynamicHelp": false,
267
+ "multiple": false,
268
+ "type": "option"
269
+ },
270
+ "yes": {
271
+ "description": "skip confirmation prompt",
272
+ "name": "yes",
273
+ "allowNo": false,
274
+ "type": "boolean"
275
+ }
276
+ },
277
+ "hasDynamicHelp": false,
278
+ "hiddenAliases": [],
279
+ "id": "delete",
280
+ "pluginAlias": "@quonfig/cli",
281
+ "pluginName": "@quonfig/cli",
282
+ "pluginType": "core",
283
+ "strict": true,
284
+ "enableJsonFlag": true,
285
+ "isESM": true,
286
+ "relativePath": [
287
+ "dist",
288
+ "commands",
289
+ "delete.js"
290
+ ]
291
+ },
205
292
  "generate-new-hex-key": {
206
293
  "aliases": [],
207
294
  "args": {},
@@ -2896,5 +2983,5 @@
2896
2983
  ]
2897
2984
  }
2898
2985
  },
2899
- "version": "0.0.21"
2986
+ "version": "0.0.23"
2900
2987
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "packageManager": "yarn@4.11.0+sha512.4e54aeace9141df2f0177c266b05ec50dc044638157dae128c471ba65994ac802122d7ab35bcd9e81641228b7dcf24867d28e750e0bcae8a05277d600008ad54",
3
3
  "name": "@quonfig/cli",
4
- "version": "0.0.21",
4
+ "version": "0.0.23",
5
5
  "bugs": {
6
6
  "url": "https://github.com/quonfig/cli/issues"
7
7
  },