argocd-axi 0.1.4 → 0.1.5

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 CHANGED
@@ -219,9 +219,7 @@ specific app.
219
219
  local-file and delete flags whatever a command passes, gives argocd no
220
220
  stdin, and enforces a timeout and an output cap. Tests fail if a new
221
221
  operation becomes reachable without updating the registry.
222
- - **A refusal registry.** Known writes and credential commands answer
223
- `REFUSED` with a reason and the raw command a human can run instead of
224
- failing blind.
222
+ - **Opt-in Application writes.** Sync, rollback, refresh and terminate-op require an explicit context, preview and `--confirm`; execution is limited by `writable-contexts` and Argo CD RBAC. Other writes and credential commands answer `REFUSED`.
225
223
  - **Redaction.** Kubernetes Secret `data`, `stringData` and their
226
224
  last-applied annotation, values under sensitive-looking keys, and env-var or
227
225
  Helm-parameter pairs with sensitive-looking names are replaced with
@@ -232,13 +230,10 @@ specific app.
232
230
  reach them.
233
231
  - **Writable contexts.** argocd-axi's own config file,
234
232
  `~/.config/argocd-axi/config` (or `$XDG_CONFIG_HOME/argocd-axi/config`),
235
- holds the allowlist of contexts where the future write tier may execute.
236
- It defaults to none, and v1 only displays it:
237
-
238
- ```yaml
239
- writable-contexts:
240
- - staging
241
- ```
233
+ holds the allowlist of contexts where Application writes may execute.
234
+ It defaults to none. To allow writes on a context, a human runs
235
+ `argocd-axi ctx writable add <context>` in a terminal and confirms the exact
236
+ context name. Use `argocd-axi ctx writable remove <context>` to revoke access.
242
237
 
243
238
  - **Version cache.** The server version is checked at most once every 12
244
239
  hours per context and server, and cached in `~/.cache/argocd-axi/`
package/ROADMAP.md CHANGED
@@ -1,53 +1,29 @@
1
1
  # Roadmap
2
2
 
3
- argocd-axi is read-only except for confirmed app syncs on contexts explicitly
4
- listed as writable. This file records the remaining write tier and shared
5
- safety rules.
3
+ argocd-axi supports confirmed app sync, rollback, refresh and terminate-op on
4
+ contexts explicitly listed as writable. This file records remaining write
5
+ tier work and shared safety rules.
6
6
 
7
- ## Next: the write tier
7
+ ## Remaining write tier
8
8
 
9
- These write verbs still answer `REFUSED` and name the raw `argocd` command a
10
- human can run:
11
-
12
- | Command | Wraps | Notes |
13
- | ------------------------------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------ |
14
- | `apps rollback <app> <history-id> --context <c>` | `argocd app rollback` | Preview synthesized from target history and current app state; refuse while auto-sync is enabled |
15
- | `apps terminate-op <app> --context <c>` | `argocd app terminate-op` | No-op when no operation is running |
16
- | `apps refresh <app> [--hard] --context <c>` | `argocd app get --refresh` | Triggers a reconcile |
17
-
18
- Later, a third tier: `apps action run <app> <action> --kind <kind>` for
19
- resource actions such as restart, and Argo Rollouts promote and abort
20
- through the same path.
9
+ Resource actions such as restart, and Argo Rollouts promote and abort, remain
10
+ future work. The supported Application writes are listed in [README.md](README.md).
21
11
 
22
12
  ### Rules every write follows
23
13
 
24
- The gate is already built and unit-tested in `src/gate.ts`, with its
25
- allowlist loaded from `src/config.ts`. The write tier only adds the commands
26
- and calls the gate.
27
-
28
- 1. **Preview by default.** Without the command's execute flag, nothing is
29
- sent. The preview shows the target context and server, the object, the
30
- change set and the preflight checks, plus the exact command that executes
31
- it. Previews work against any context.
32
- 2. **A local allowlist of writable contexts, default none.** Executing needs
33
- the context listed in argocd-axi's own config file,
34
- `~/.config/argocd-axi/config` (or `$XDG_CONFIG_HOME/argocd-axi/config`):
35
-
36
- ```yaml
37
- writable-contexts:
38
- - staging
39
- ```
14
+ The gate is built in `src/gate.ts`, with its allowlist loaded from
15
+ `src/config.ts`. New writes must use the gate and the registered operation path.
40
16
 
41
- A production context stays read-only until a human opts it in locally.
42
- `ctx` and the home view already show `writableContexts: none`.
17
+ 1. **Preview by default.** Without `--confirm`, no write is sent. Each write
18
+ previews the target action before execution.
19
+ 2. **A local allowlist of writable contexts, default none.** See [README.md](README.md)
20
+ for how a human manages the allowlist.
43
21
 
44
22
  3. **An explicit target.** Every write names `--context <name>`; the current
45
23
  context is never used implicitly, and the result echoes the context and
46
24
  server.
47
- 4. **Argo CD's own vocabulary.** The execute flag and every other flag are
48
- named after Argo CD's terms for each command, not a shared cross-tool
49
- vocabulary. The exact execute-flag spelling is settled when each command is
50
- built.
25
+ 4. **Explicit confirmation.** Every supported Application write requires
26
+ `--confirm` to execute.
51
27
  5. **Idempotent.** Asking for a state that already exists (syncing an app
52
28
  that is already synced to the target revision, terminating when nothing
53
29
  runs) is a no-op with exit 0. Exit 1 only when the intent cannot be met:
@@ -93,4 +69,3 @@ instances (version, SSO provider, app count) are known.
93
69
 
94
70
  Submission to the [AXI community catalog](https://github.com/kunchenguid/axi)
95
71
  comes after v1. npm releases are automated; see the Releases section in
96
- [README.md](README.md).
@@ -14,7 +14,10 @@ export function buildArgv(op, args, context) {
14
14
  const operation = OPERATIONS[op];
15
15
  for (const arg of args) {
16
16
  const name = arg.startsWith("--") ? arg.split("=")[0] : arg;
17
- if (name !== undefined && FORBIDDEN_ARGS.includes(name)) {
17
+ if (name !== undefined &&
18
+ FORBIDDEN_ARGS.includes(name) &&
19
+ !(op === "app refresh" &&
20
+ (name === "--refresh" || name === "--hard-refresh"))) {
18
21
  throw axiError(`internal guard: argument ${name} is never passed to argocd`, CODES.REFUSED);
19
22
  }
20
23
  }
package/dist/src/cli.js CHANGED
@@ -58,6 +58,12 @@ export const COMMANDS = Object.assign(Object.create(null), {
58
58
  ...Object.fromEntries(TOP_LEVEL_REFUSALS.map((key) => [key, refusalCommand(key)])),
59
59
  });
60
60
  export async function main(argv = process.argv.slice(2)) {
61
+ if (argv.length === 1 &&
62
+ argv[0] !== undefined &&
63
+ ["--version", "-v", "-V"].includes(argv[0])) {
64
+ process.stdout.write(`${encode(VERSION)}\n`);
65
+ return;
66
+ }
61
67
  await runAxiCli({
62
68
  description: DESCRIPTION,
63
69
  version: VERSION,
@@ -550,7 +550,7 @@ export function parseDiffHeaders(text) {
550
550
  }
551
551
  return rows;
552
552
  }
553
- export async function appsDiff(args) {
553
+ export async function appsDiff(args, includeHelp = true) {
554
554
  const parsed = parseArgs(APPS_DIFF_SPEC, args);
555
555
  const name = validateName("app", parsed.positionals[0]);
556
556
  const revision = getString(parsed, "--revision");
@@ -584,9 +584,11 @@ export async function appsDiff(args) {
584
584
  count: `${plural(rows.length, "resource")} ${rows.length === 1 ? "differs" : "differ"}`,
585
585
  resources: redactor.deep(rows),
586
586
  }),
587
- ], redactor, [
588
- `Run \`argocd-axi apps diff ${name} --full${scope}\` to see the line-level diff`,
589
- ]);
587
+ ], redactor, includeHelp
588
+ ? [
589
+ `Run \`argocd-axi apps diff ${name} --full${scope}\` to see the line-level diff`,
590
+ ]
591
+ : []);
590
592
  }
591
593
  const argv = [name, "--exit-code=false"];
592
594
  if (revision !== undefined)
@@ -617,7 +619,7 @@ export async function appsDiff(args) {
617
619
  const lines = redactor.text(text).split("\n");
618
620
  blocks.push(renderTextBlock("diff", lines));
619
621
  }
620
- return finish(blocks, redactor, full
622
+ return finish(blocks, redactor, full || !includeHelp
621
623
  ? []
622
624
  : [
623
625
  `Run \`argocd-axi apps diff ${name}${revision ? ` --revision ${revision}` : ""} --full${scope}\` to see the line-level diff`,
@@ -1055,7 +1057,7 @@ export async function appsSync(args) {
1055
1057
  executeFlag: "--confirm",
1056
1058
  }, loadConfig().config);
1057
1059
  const options = { context: gate.context };
1058
- const diff = await appsDiff([name, "--context", gate.context]);
1060
+ const diff = await appsDiff([name, "--context", gate.context], false);
1059
1061
  const app = await getApp(name, options);
1060
1062
  const project = validateName("project", app.spec?.project ?? "default");
1061
1063
  const windows = (await argocdJson("proj windows list", [project, "-o", "json"], options)) ?? [];
@@ -1188,6 +1190,213 @@ function refuseActionRun() {
1188
1190
  "A human can run one with the raw CLI: `argocd app actions run <app> <action> --kind <kind>`",
1189
1191
  ]);
1190
1192
  }
1193
+ // ---------------------------------------------------------------- app writes
1194
+ function writeSpec(path, summary, positionals) {
1195
+ return {
1196
+ path,
1197
+ summary,
1198
+ positionals,
1199
+ flags: [
1200
+ { name: "--confirm", kind: "bool", description: "Execute after preview" },
1201
+ ...(path === "apps refresh"
1202
+ ? []
1203
+ : [
1204
+ {
1205
+ name: "--timeout",
1206
+ kind: "value",
1207
+ valueName: "s",
1208
+ description: "Maximum wait in seconds (default 120)",
1209
+ },
1210
+ ]),
1211
+ ...(path === "apps refresh"
1212
+ ? [
1213
+ {
1214
+ name: "--hard",
1215
+ kind: "bool",
1216
+ description: "Hard refresh, bypassing the cache",
1217
+ },
1218
+ ]
1219
+ : []),
1220
+ CONTEXT_FLAG,
1221
+ ],
1222
+ examples: [
1223
+ `argocd-axi ${path} ${path === "apps rollback" ? "guestbook 3" : "guestbook"} --context staging`,
1224
+ ],
1225
+ };
1226
+ }
1227
+ const WRITE_TIMEOUT = { default: 120, min: 1, max: 3600 };
1228
+ async function checkWritePermission(action, app, name, options) {
1229
+ const permission = await runArgocd("account can-i", [action, "applications", `${app.spec?.project ?? "default"}/${name}`], { ...options, allowFailure: true });
1230
+ if (permission.exitCode !== 0 || !/^yes\s*$/i.test(permission.stdout.trim()))
1231
+ throw axiError(`REFUSED: Argo CD RBAC denies ${action} for ${name}`, CODES.REFUSED);
1232
+ }
1233
+ async function runApplicationWrite(op, args, options, name) {
1234
+ try {
1235
+ await runArgocd(op, args, options);
1236
+ }
1237
+ catch (error) {
1238
+ if (error instanceof AxiError && error.code === CODES.FORBIDDEN)
1239
+ throw axiError(`REFUSED: Argo CD RBAC denied ${op.slice(4)} for ${name}`, CODES.REFUSED);
1240
+ throw error;
1241
+ }
1242
+ }
1243
+ function writeOutcome(phase, timedOut, completed) {
1244
+ if (phase === "Failed" || phase === "Error")
1245
+ return "failed";
1246
+ if (phase === "Succeeded")
1247
+ return completed;
1248
+ if (timedOut)
1249
+ return "waiting";
1250
+ return phase ?? "unknown";
1251
+ }
1252
+ async function waitForWrite(name, timeout, options) {
1253
+ try {
1254
+ const result = await runArgocd("app wait", [name, "--operation", "--timeout", String(timeout)], { ...options, allowFailure: true, timeoutMs: (timeout + 10) * 1000 });
1255
+ if (result.exitCode !== 0) {
1256
+ const failure = mapArgocdFailure(result.stderr || result.stdout);
1257
+ if (!/timed? out|timeout/i.test(failure.message))
1258
+ throw failure;
1259
+ return { app: await getApp(name, options), timedOut: true };
1260
+ }
1261
+ return { app: await getApp(name, options), timedOut: false };
1262
+ }
1263
+ catch (error) {
1264
+ if (!(error instanceof AxiError) || error.code !== CODES.TIMEOUT)
1265
+ throw error;
1266
+ return { app: await getApp(name, options), timedOut: true };
1267
+ }
1268
+ }
1269
+ const APPS_ROLLBACK_SPEC = writeSpec("apps rollback", "Preview and optionally roll back one Application", [
1270
+ APP_ARG,
1271
+ {
1272
+ name: "history-id",
1273
+ required: true,
1274
+ description: "Deployment history id",
1275
+ },
1276
+ ]);
1277
+ export async function appsRollback(args) {
1278
+ const parsed = parseArgs(APPS_ROLLBACK_SPEC, args);
1279
+ const name = validateName("app", parsed.positionals[0]);
1280
+ const historyId = parsed.positionals[1];
1281
+ if (!/^\d+$/.test(historyId))
1282
+ throw validationError("history-id must be a non-negative decimal integer");
1283
+ const id = Number(historyId);
1284
+ if (!Number.isSafeInteger(id) || id < 0)
1285
+ throw validationError("history-id must be a non-negative integer");
1286
+ const context = getString(parsed, "--context"), confirm = getBool(parsed, "--confirm"), timeout = getInt(parsed, "--timeout", WRITE_TIMEOUT);
1287
+ const gate = evaluateWriteGate({
1288
+ command: "apps rollback",
1289
+ context,
1290
+ execute: confirm,
1291
+ executeFlag: "--confirm",
1292
+ }, loadConfig().config);
1293
+ const options = { context: gate.context }, app = await getApp(name, options);
1294
+ const history = app.status?.history?.find((entry) => entry.id === id);
1295
+ if (!history)
1296
+ throw axiError(`REFUSED: history id ${id} not found for ${name}`, CODES.REFUSED);
1297
+ if (autoSyncSummary(app) !== "manual")
1298
+ throw axiError(`REFUSED: disable auto-sync before rolling back ${name}`, CODES.REFUSED);
1299
+ const target = history.revisions?.join(", ") ?? history.revision ?? "unknown";
1300
+ const previewBlocks = [
1301
+ encodeObject({
1302
+ rollback: name,
1303
+ currentRevision: syncRevision(app),
1304
+ targetHistoryId: id,
1305
+ targetRevision: target,
1306
+ }),
1307
+ ];
1308
+ if (!confirm)
1309
+ return finish(previewBlocks, undefined, [
1310
+ "No changes made; add --confirm to roll back",
1311
+ ...(gate.mode === "preview" ? gate.help : []),
1312
+ ]);
1313
+ await checkWritePermission("sync", app, name, options);
1314
+ await runApplicationWrite("app rollback", [name, String(id), "--info", "initiated-by=argocd-axi"], options, name);
1315
+ const current = await waitForWrite(name, timeout, options);
1316
+ const outcome = writeOutcome(current.app.status?.operationState?.phase, current.timedOut, "submitted");
1317
+ return finish([
1318
+ ...previewBlocks,
1319
+ encodeObject({
1320
+ rollback: outcome,
1321
+ historyId: id,
1322
+ phase: current.app.status?.operationState?.phase,
1323
+ operation: current.app.status?.operationState,
1324
+ }),
1325
+ ], undefined, outcome === "waiting" ? ["Wait again with `apps wait`"] : []);
1326
+ }
1327
+ const APPS_REFRESH_SPEC = writeSpec("apps refresh", "Preview and optionally refresh one Application", [APP_ARG]);
1328
+ export async function appsRefresh(args) {
1329
+ const parsed = parseArgs(APPS_REFRESH_SPEC, args);
1330
+ const name = validateName("app", parsed.positionals[0]);
1331
+ const context = getString(parsed, "--context"), confirm = getBool(parsed, "--confirm"), hard = getBool(parsed, "--hard");
1332
+ const gate = evaluateWriteGate({
1333
+ command: "apps refresh",
1334
+ context,
1335
+ execute: confirm,
1336
+ executeFlag: "--confirm",
1337
+ }, loadConfig().config);
1338
+ const options = { context: gate.context }, app = await getApp(name, options);
1339
+ const blocks = [
1340
+ encodeObject({
1341
+ refresh: name,
1342
+ mode: hard ? "hard" : "normal",
1343
+ reRead: hard ? "live state and source caches" : "live state",
1344
+ }),
1345
+ ];
1346
+ if (!confirm)
1347
+ return finish(blocks, undefined, [
1348
+ "No changes made; add --confirm to refresh",
1349
+ ...(gate.mode === "preview" ? gate.help : []),
1350
+ ]);
1351
+ await checkWritePermission("update", app, name, options);
1352
+ await runApplicationWrite("app refresh", [name, hard ? "--hard-refresh" : "--refresh"], options, name);
1353
+ return finish([...blocks, encodeObject({ refresh: "submitted" })], undefined);
1354
+ }
1355
+ const APPS_TERMINATE_SPEC = writeSpec("apps terminate-op", "Preview and optionally terminate one running operation", [APP_ARG]);
1356
+ export async function appsTerminateOp(args) {
1357
+ const parsed = parseArgs(APPS_TERMINATE_SPEC, args);
1358
+ const name = validateName("app", parsed.positionals[0]);
1359
+ const context = getString(parsed, "--context"), confirm = getBool(parsed, "--confirm"), timeout = getInt(parsed, "--timeout", WRITE_TIMEOUT);
1360
+ const gate = evaluateWriteGate({
1361
+ command: "apps terminate-op",
1362
+ context,
1363
+ execute: confirm,
1364
+ executeFlag: "--confirm",
1365
+ }, loadConfig().config);
1366
+ const options = { context: gate.context }, app = await getApp(name, options), phase = app.status?.operationState?.phase;
1367
+ if (!isOperationRunning(app))
1368
+ return finish([
1369
+ encodeObject({
1370
+ terminateOperation: name,
1371
+ status: "no operation running",
1372
+ }),
1373
+ ], undefined);
1374
+ const blocks = [
1375
+ encodeObject({
1376
+ terminateOperation: name,
1377
+ phase,
1378
+ startedAt: app.status?.operationState?.startedAt,
1379
+ message: app.status?.operationState?.message,
1380
+ }),
1381
+ ];
1382
+ if (!confirm)
1383
+ return finish(blocks, undefined, [
1384
+ "No changes made; add --confirm to terminate the operation",
1385
+ ...(gate.mode === "preview" ? gate.help : []),
1386
+ ]);
1387
+ await checkWritePermission("update", app, name, options);
1388
+ await runApplicationWrite("app terminate-op", [name], options, name);
1389
+ const current = await waitForWrite(name, timeout, options);
1390
+ const outcome = writeOutcome(current.app.status?.operationState?.phase, current.timedOut, "submitted");
1391
+ return finish([
1392
+ ...blocks,
1393
+ encodeObject({
1394
+ terminateOperation: outcome,
1395
+ phase: current.app.status?.operationState?.phase,
1396
+ operation: current.app.status?.operationState,
1397
+ }),
1398
+ ], undefined, outcome === "waiting" ? ["Wait again with `apps wait`"] : []);
1399
+ }
1191
1400
  // ---------------------------------------------------------------- dispatcher
1192
1401
  const SUBCOMMANDS = {
1193
1402
  view: { spec: APPS_VIEW_SPEC, run: appsView },
@@ -1199,6 +1408,9 @@ const SUBCOMMANDS = {
1199
1408
  wait: { spec: APPS_WAIT_SPEC, run: appsWait },
1200
1409
  actions: { spec: APPS_ACTIONS_SPEC, run: appsActions },
1201
1410
  sync: { spec: APPS_SYNC_SPEC, run: appsSync },
1411
+ rollback: { spec: APPS_ROLLBACK_SPEC, run: appsRollback },
1412
+ refresh: { spec: APPS_REFRESH_SPEC, run: appsRefresh },
1413
+ "terminate-op": { spec: APPS_TERMINATE_SPEC, run: appsTerminateOp },
1202
1414
  };
1203
1415
  export const APPS_SPECS = [
1204
1416
  APPS_LIST_SPEC,
@@ -1,4 +1,6 @@
1
- import { runArgocd } from "../argocd.js";
1
+ import { runArgocd, argocdJson } from "../argocd.js";
2
+ import { addWritableContext, removeWritableContext } from "../config.js";
3
+ import { createInterface } from "node:readline/promises";
2
4
  import { CONTEXT_FLAG, getString, parseArgs, } from "../args.js";
3
5
  import { loadConfig } from "../config.js";
4
6
  import { describeTarget, effectiveServer, listContexts, resolveTarget, validateContextName, } from "../context.js";
@@ -135,13 +137,127 @@ export async function ctxUse(args) {
135
137
  await runArgocd("context use", [name]);
136
138
  return finish([encodeObject({ context: `switched to ${name}`, server: match.server })], undefined, ["Run `argocd-axi` for a live overview of this context"]);
137
139
  }
140
+ const WRITABLE_SPEC = {
141
+ path: "ctx writable",
142
+ summary: "Manage contexts allowed to perform writes",
143
+ positionals: [],
144
+ flags: [],
145
+ examples: ["argocd-axi ctx writable list"],
146
+ };
147
+ const WRITABLE_ADD_SPEC = {
148
+ path: "ctx writable add",
149
+ summary: "Allow writes on a context after interactive confirmation",
150
+ positionals: [
151
+ {
152
+ name: "context",
153
+ required: true,
154
+ description: "Configured Argo CD context",
155
+ },
156
+ ],
157
+ flags: [],
158
+ examples: ["argocd-axi ctx writable add staging"],
159
+ };
160
+ const WRITABLE_REMOVE_SPEC = {
161
+ path: "ctx writable remove",
162
+ summary: "Remove write access from a context",
163
+ positionals: [
164
+ {
165
+ name: "context",
166
+ required: true,
167
+ description: "Configured Argo CD context",
168
+ },
169
+ ],
170
+ flags: [],
171
+ examples: ["argocd-axi ctx writable remove staging"],
172
+ };
173
+ const WRITABLE_LIST_SPEC = {
174
+ path: "ctx writable list",
175
+ summary: "List contexts allowed to perform writes",
176
+ positionals: [],
177
+ flags: [],
178
+ examples: ["argocd-axi ctx writable list"],
179
+ };
180
+ async function writableAdd(args) {
181
+ const parsed = parseArgs(WRITABLE_ADD_SPEC, args);
182
+ const name = validateContextName(parsed.positionals[0]);
183
+ const listing = await listContexts();
184
+ if (listing.notInstalled)
185
+ throw listing.notInstalled;
186
+ if (listing.unavailable) {
187
+ throw axiError(`cannot list Argo CD contexts: ${listing.unavailable}`, CODES.NO_CONTEXT, LOGIN_HELP);
188
+ }
189
+ const match = listing.contexts.find((context) => context.name === name);
190
+ if (!match)
191
+ throw axiError(`Argo CD context ${name} is not configured`, CODES.CONTEXT_NOT_FOUND);
192
+ if (loadConfig().config.writableContexts.includes(name))
193
+ return finish([
194
+ encodeObject({
195
+ writableContext: `${name} is already writable (no change)`,
196
+ }),
197
+ ], undefined);
198
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
199
+ throw axiError("writable context changes require an interactive terminal", CODES.REFUSED);
200
+ const info = await argocdJson("account get-user-info", ["-o", "json"], { context: name });
201
+ process.stdout.write(`Context: ${name}\nServer: ${match.server}\nUser: ${info.username ?? "unknown"}\nType ${name} to allow writes: `);
202
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
203
+ let answer;
204
+ try {
205
+ answer = await rl.question("");
206
+ }
207
+ finally {
208
+ rl.close();
209
+ }
210
+ if (answer !== name)
211
+ throw axiError("confirmation did not match; no changes made", CODES.REFUSED);
212
+ const changed = addWritableContext(name);
213
+ return finish([
214
+ encodeObject({
215
+ writableContext: changed
216
+ ? `added ${name}`
217
+ : `${name} is already writable (no change)`,
218
+ }),
219
+ ], undefined);
220
+ }
221
+ async function writableRemove(args) {
222
+ const parsed = parseArgs(WRITABLE_REMOVE_SPEC, args);
223
+ const name = validateContextName(parsed.positionals[0]);
224
+ const changed = removeWritableContext(name);
225
+ return finish([
226
+ encodeObject({
227
+ writableContext: changed
228
+ ? `removed ${name}`
229
+ : `${name} is not in writable-contexts (no change)`,
230
+ }),
231
+ ], undefined);
232
+ }
233
+ async function writableList(args) {
234
+ parseArgs(WRITABLE_LIST_SPEC, args);
235
+ return finish([encodeObject({ writableContexts: loadConfig().config.writableContexts })], undefined);
236
+ }
237
+ const WRITABLE_SUBCOMMANDS = {
238
+ add: { spec: WRITABLE_ADD_SPEC, run: writableAdd },
239
+ remove: { spec: WRITABLE_REMOVE_SPEC, run: writableRemove },
240
+ list: { spec: WRITABLE_LIST_SPEC, run: writableList },
241
+ };
138
242
  const SUBCOMMANDS = {
139
243
  use: { spec: CTX_USE_SPEC, run: ctxUse },
244
+ writable: {
245
+ spec: WRITABLE_SPEC,
246
+ run: (args) => dispatch("ctx writable", args, WRITABLE_SUBCOMMANDS, {
247
+ spec: WRITABLE_SPEC,
248
+ run: async () => {
249
+ throw validationError("expected list, add, or remove for `ctx writable`");
250
+ },
251
+ }),
252
+ },
140
253
  };
141
254
  export const CTX_SPECS = [CTX_SPEC, CTX_USE_SPEC];
142
255
  export async function ctxCommand(args) {
143
256
  const first = args[0];
144
- if (first !== undefined && !first.startsWith("-") && first !== "use") {
257
+ if (first !== undefined &&
258
+ !first.startsWith("-") &&
259
+ first !== "use" &&
260
+ first !== "writable") {
145
261
  throw validationError(`unexpected argument ${first} for \`ctx\``, [
146
262
  `To switch contexts, run \`argocd-axi ctx use ${first}\``,
147
263
  `To read another context without switching, run \`argocd-axi ctx --context ${first}\``,
@@ -1,7 +1,8 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, writeFileSync, renameSync, chmodSync, statSync, mkdirSync, unlinkSync, } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
- import { parse } from "yaml";
5
+ import { parse, parseDocument } from "yaml";
5
6
  import { CODES, axiError } from "./errors.js";
6
7
  const KNOWN_KEYS = ["writable-contexts"];
7
8
  export function configPath() {
@@ -49,6 +50,53 @@ export function parseConfig(path, text) {
49
50
  }
50
51
  return { writableContexts: [...new Set(writableContexts)] };
51
52
  }
53
+ // ponytail: concurrent CLI writers can lose updates; serialize if concurrent config mutations become supported.
54
+ function updateWritableContext(name, add) {
55
+ const path = configPath();
56
+ mkdirSync(join(path, ".."), { recursive: true });
57
+ const loaded = loadConfig();
58
+ const contexts = loaded.config.writableContexts;
59
+ const updated = add
60
+ ? [...new Set([...contexts, name])]
61
+ : contexts.filter((item) => item !== name);
62
+ if (updated.length === contexts.length &&
63
+ updated.every((item, index) => item === contexts[index]))
64
+ return false;
65
+ mkdirSync(join(loaded.path, ".."), { recursive: true });
66
+ const source = loaded.exists ? readFileSync(loaded.path, "utf8") : "";
67
+ const doc = parseDocument(source || "{}");
68
+ if (doc.toJS() === null)
69
+ doc.contents = null;
70
+ const root = doc.contents;
71
+ if (root !== null && (root === undefined || !("set" in root)))
72
+ throw invalid(loaded.path, "the top level must be a mapping");
73
+ doc.set("writable-contexts", updated);
74
+ const temp = `${loaded.path}.${randomUUID()}.tmp`;
75
+ try {
76
+ writeFileSync(temp, doc.toString(), {
77
+ mode: loaded.exists ? statSync(loaded.path).mode & 0o777 : 0o600,
78
+ });
79
+ if (loaded.exists)
80
+ chmodSync(temp, statSync(loaded.path).mode & 0o777);
81
+ renameSync(temp, loaded.path);
82
+ return true;
83
+ }
84
+ catch (error) {
85
+ try {
86
+ unlinkSync(temp);
87
+ }
88
+ catch {
89
+ /* no temporary file */
90
+ }
91
+ throw error;
92
+ }
93
+ }
94
+ export function addWritableContext(name) {
95
+ return updateWritableContext(name, true);
96
+ }
97
+ export function removeWritableContext(name) {
98
+ return updateWritableContext(name, false);
99
+ }
52
100
  export function loadConfig() {
53
101
  const path = configPath();
54
102
  let text;
package/dist/src/gate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { configPath } from "./config.js";
1
+ import {} from "./config.js";
2
2
  import { validateContextName } from "./context.js";
3
3
  import { CODES, axiError, validationError } from "./errors.js";
4
4
  /**
@@ -37,7 +37,7 @@ export function evaluateWriteGate(request, config) {
37
37
  }
38
38
  const context = validateContextName(request.context);
39
39
  const writable = isWritableContext(config, context);
40
- const allowHint = `A human adds ${context} to writable-contexts in ${configPath()} to allow writes there`;
40
+ const allowHint = `A human can allow writes by running \`argocd-axi ctx writable add ${context}\` in a terminal`;
41
41
  if (!request.execute) {
42
42
  return {
43
43
  mode: "preview",
package/dist/src/help.js CHANGED
@@ -13,6 +13,9 @@ export const COMMAND_LINES = [
13
13
  "apps resources <app> [--problems --orphaned]: managed resources with sync and health",
14
14
  "apps diff <app> [--revision <rev>] [--full]: what differs from Git, per resource; raw diff with --full",
15
15
  "apps sync <app> [--confirm] [--prune] [--timeout <s>]: preview, then optionally sync one app",
16
+ "apps rollback <app> <history-id> [--confirm] [--timeout <s>]: preview revision, then optionally roll back",
17
+ "apps refresh <app> [--hard] [--confirm]: preview, then optionally refresh one app",
18
+ "apps terminate-op <app> [--confirm] [--timeout <s>]: preview, then optionally stop a running operation",
16
19
  "apps history <app>: deployments, newest first",
17
20
  "apps manifests <app> [--source git|live] [--kind <k>] [--full]: rendered manifests, Secret data redacted",
18
21
  "apps logs <app> [--kind --name --tail --since --filter -p]: recent pod logs, never follows",
@@ -39,7 +42,7 @@ export const TOP_HELP = `${renderBlocks([
39
42
  "argocd-axi apps diff payments-api",
40
43
  ]),
41
44
  renderHelpBlock("notes", [
42
- "Read-only except opt-in writes on writable contexts: apps sync requires --confirm and never prunes unless --prune is given; other writes answer REFUSED",
45
+ "Read-only except opt-in writes on writable contexts: apps sync, rollback, refresh and terminate-op require --confirm; sync never prunes unless --prune is given",
43
46
  "Output is TOON on stdout, errors included; exit 0 = success (an unhealthy app is data, not an error), 1 = error, 2 = usage",
44
47
  "Secret-looking values are redacted and counted; no flag reveals them",
45
48
  "Uses your existing argocd CLI session or ARGOCD_AUTH_TOKEN; never logs in, never reads the argocd config file",
@@ -32,6 +32,24 @@ export const OPERATIONS = {
32
32
  network: true,
33
33
  timeoutMs: READ_TIMEOUT_MS,
34
34
  },
35
+ "app rollback": {
36
+ argv: ["app", "rollback"],
37
+ tier: "write",
38
+ network: true,
39
+ timeoutMs: READ_TIMEOUT_MS,
40
+ },
41
+ "app terminate-op": {
42
+ argv: ["app", "terminate-op"],
43
+ tier: "write",
44
+ network: true,
45
+ timeoutMs: READ_TIMEOUT_MS,
46
+ },
47
+ "app refresh": {
48
+ argv: ["app", "get"],
49
+ tier: "write",
50
+ network: true,
51
+ timeoutMs: READ_TIMEOUT_MS,
52
+ },
35
53
  "app resources": {
36
54
  argv: ["app", "resources"],
37
55
  tier: "read",
@@ -178,27 +196,6 @@ const PLANNED = "planned for the write tier, behind preview-by-default and the w
178
196
  * answer REFUSED with the raw argocd route a human can take today.
179
197
  */
180
198
  export const PLANNED_WRITES = {
181
- "apps rollback": {
182
- reason: `apps rollback is ${PLANNED}`,
183
- help: [
184
- "List deployable history ids with `argocd-axi apps history <app>`",
185
- "A human can roll back with the raw CLI: `argocd app rollback <app> <history-id>`",
186
- ],
187
- },
188
- "apps terminate-op": {
189
- reason: `apps terminate-op is ${PLANNED}`,
190
- help: [
191
- "Inspect the running operation with `argocd-axi apps view <app>`",
192
- "A human can stop it with the raw CLI: `argocd app terminate-op <app>`",
193
- ],
194
- },
195
- "apps refresh": {
196
- reason: `apps refresh triggers a reconcile, so it counts as a write; it is ${PLANNED}`,
197
- help: [
198
- "Read the last reconciled state with `argocd-axi apps view <app>`",
199
- "A human can refresh with the raw CLI: `argocd app get <app> --refresh`",
200
- ],
201
- },
202
199
  "apps action": {
203
200
  reason: `running resource actions is ${PLANNED}`,
204
201
  help: [
package/dist/src/skill.js CHANGED
@@ -6,7 +6,7 @@ import { COMMAND_LINES, DESCRIPTION } from "./help.js";
6
6
  * is stale.
7
7
  */
8
8
  export const SKILL_DESCRIPTION = "Inspect Argo CD through the argocd-axi CLI - which applications are OutOfSync, Degraded or failing to sync and why, " +
9
- "per-resource diffs against Git, deployment history, pod logs, project sync windows and RBAC checks, plus confirmed app syncs on writable contexts. " +
9
+ "per-resource diffs against Git, deployment history, pod logs, project sync windows and RBAC checks, plus confirmed app sync, rollback, refresh, and operation termination on writable contexts. " +
10
10
  "Use whenever a task involves Argo CD or GitOps deployments: an app stuck or unhealthy, a sync that failed, " +
11
11
  "what changed in a rollout, or whether a sync is allowed right now.";
12
12
  export function createSkillMarkdown() {
@@ -28,12 +28,12 @@ argocd-axi wraps the \`argocd\` CLI, which must be on PATH and already logged in
28
28
  It uses that session as is and adds no auth of its own; it never logs in, never prompts, and never reads the argocd config file.
29
29
  Run \`npx -y argocd-axi auth guide\` for setup instructions; the README is the authority for credential setup. The tool never logs in. If a command answers \`AUTH_EXPIRED\`, show the exact operator login command when the server is known; otherwise show the fallback login syntax and ask for the server address. An unreachable server is a separate error.
30
30
 
31
- It is read-only except for opt-in writes on contexts listed in \`writable-contexts\`. \`apps sync\` previews the diff and does nothing unless \`--confirm\` is passed; pruning also requires \`--prune\`. Other writes answer \`REFUSED\`.
31
+ It supports opt-in writes on contexts listed in \`writable-contexts\`: \`apps sync\`, \`apps rollback\`, \`apps refresh\`, and \`apps terminate-op\` preview first and require \`--confirm\` to act. Sync pruning also requires \`--prune\`. Other writes answer \`REFUSED\`.
32
32
  For anything argocd-axi does not cover, the raw \`argocd\` CLI is the fallback.
33
33
 
34
34
  ## Setup
35
35
 
36
- Run \`npx -y argocd-axi auth guide\` for offline instructions. A human operator logs in interactively with \`argocd login <server> --sso --grpc-web\`; agents and CI use \`ARGOCD_SERVER\` and \`ARGOCD_AUTH_TOKEN\` from a scoped account token. The user owns login; argocd-axi never logs in. Verify access with \`npx -y argocd-axi whoami\`.
36
+ Run \`npx -y argocd-axi auth guide\` for offline instructions. A human operator logs in interactively with \`argocd login <server> --sso --grpc-web\`; agents and CI use \`ARGOCD_SERVER\` and \`ARGOCD_AUTH_TOKEN\` from a scoped account token. The user owns login; argocd-axi never logs in. Verify access with \`npx -y argocd-axi whoami\`. To enable writes on a context, a human runs \`argocd-axi ctx writable add <context>\` in a terminal and confirms the context name.
37
37
 
38
38
  ## Workflow
39
39
 
package/dist/src/toon.js CHANGED
@@ -20,7 +20,7 @@ export function encodeObject(value) {
20
20
  }
21
21
  /** Render next-step suggestions as a TOON list. */
22
22
  export function renderHelp(lines) {
23
- return encodeObject({ help: lines });
23
+ return lines.length === 0 ? "" : encodeObject({ help: lines });
24
24
  }
25
25
  /** Render help sections as TOON list items. */
26
26
  export function renderHelpBlock(label, lines) {
@@ -1,3 +1,3 @@
1
1
  // Leaf module: node builtins only, so the bin fast path can answer
2
2
  // `--version` without loading the command graph.
3
- export const VERSION = "0.1.4"; // x-release-please-version
3
+ export const VERSION = "0.1.5"; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argocd-axi",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Agent-ergonomic Argo CD CLI with opt-in writes - AXI-compliant argocd wrapper with token-efficient TOON output",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: argocd-axi
3
- description: "Inspect Argo CD through the argocd-axi CLI - which applications are OutOfSync, Degraded or failing to sync and why, per-resource diffs against Git, deployment history, pod logs, project sync windows and RBAC checks, plus confirmed app syncs on writable contexts. Use whenever a task involves Argo CD or GitOps deployments: an app stuck or unhealthy, a sync that failed, what changed in a rollout, or whether a sync is allowed right now."
3
+ description: "Inspect Argo CD through the argocd-axi CLI - which applications are OutOfSync, Degraded or failing to sync and why, per-resource diffs against Git, deployment history, pod logs, project sync windows and RBAC checks, plus confirmed app sync, rollback, refresh, and operation termination on writable contexts. Use whenever a task involves Argo CD or GitOps deployments: an app stuck or unhealthy, a sync that failed, what changed in a rollout, or whether a sync is allowed right now."
4
4
  user-invocable: false
5
5
  ---
6
6
 
@@ -15,12 +15,12 @@ argocd-axi wraps the `argocd` CLI, which must be on PATH and already logged in (
15
15
  It uses that session as is and adds no auth of its own; it never logs in, never prompts, and never reads the argocd config file.
16
16
  Run `npx -y argocd-axi auth guide` for setup instructions; the README is the authority for credential setup. The tool never logs in. If a command answers `AUTH_EXPIRED`, show the exact operator login command when the server is known; otherwise show the fallback login syntax and ask for the server address. An unreachable server is a separate error.
17
17
 
18
- It is read-only except for opt-in writes on contexts listed in `writable-contexts`. `apps sync` previews the diff and does nothing unless `--confirm` is passed; pruning also requires `--prune`. Other writes answer `REFUSED`.
18
+ It supports opt-in writes on contexts listed in `writable-contexts`: `apps sync`, `apps rollback`, `apps refresh`, and `apps terminate-op` preview first and require `--confirm` to act. Sync pruning also requires `--prune`. Other writes answer `REFUSED`.
19
19
  For anything argocd-axi does not cover, the raw `argocd` CLI is the fallback.
20
20
 
21
21
  ## Setup
22
22
 
23
- Run `npx -y argocd-axi auth guide` for offline instructions. A human operator logs in interactively with `argocd login <server> --sso --grpc-web`; agents and CI use `ARGOCD_SERVER` and `ARGOCD_AUTH_TOKEN` from a scoped account token. The user owns login; argocd-axi never logs in. Verify access with `npx -y argocd-axi whoami`.
23
+ Run `npx -y argocd-axi auth guide` for offline instructions. A human operator logs in interactively with `argocd login <server> --sso --grpc-web`; agents and CI use `ARGOCD_SERVER` and `ARGOCD_AUTH_TOKEN` from a scoped account token. The user owns login; argocd-axi never logs in. Verify access with `npx -y argocd-axi whoami`. To enable writes on a context, a human runs `argocd-axi ctx writable add <context>` in a terminal and confirms the context name.
24
24
 
25
25
  ## Workflow
26
26
 
@@ -34,7 +34,7 @@ Run `npx -y argocd-axi auth guide` for offline instructions. A human operator lo
34
34
  ## Commands
35
35
 
36
36
  ```
37
- commands[20]:
37
+ commands[23]:
38
38
  (no command): live overview - context, server, user, apps needing attention with the reason, running operations
39
39
  ctx [--context <c>]: current context, server and CLI version, local only (what the session hook prints)
40
40
  ctx use <context>: switch the argocd CLI's current context (local config only)
@@ -46,6 +46,9 @@ commands[20]:
46
46
  apps resources <app> [--problems --orphaned]: managed resources with sync and health
47
47
  apps diff <app> [--revision <rev>] [--full]: what differs from Git, per resource; raw diff with --full
48
48
  apps sync <app> [--confirm] [--prune] [--timeout <s>]: preview, then optionally sync one app
49
+ apps rollback <app> <history-id> [--confirm] [--timeout <s>]: preview revision, then optionally roll back
50
+ apps refresh <app> [--hard] [--confirm]: preview, then optionally refresh one app
51
+ apps terminate-op <app> [--confirm] [--timeout <s>]: preview, then optionally stop a running operation
49
52
  apps history <app>: deployments, newest first
50
53
  apps manifests <app> [--source git|live] [--kind <k>] [--full]: rendered manifests, Secret data redacted
51
54
  apps logs <app> [--kind --name --tail --since --filter -p]: recent pod logs, never follows