argocd-axi 0.1.4 → 0.1.6

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
@@ -48,8 +48,8 @@ argocd-axi turns that into small, definitive answers:
48
48
  the raw text is one `--full` away.
49
49
  - **Opt-in writes.** `apps sync <app>` previews the per-resource diff and
50
50
  makes no change unless `--confirm` is passed. It never prunes unless
51
- `--prune` is explicit, and execution is limited to configured writable
52
- contexts. Other writes remain refused.
51
+ `--prune` is explicit. Resource actions and automated-sync changes also
52
+ preview by default and execute only on configured writable contexts.
53
53
  - **Safe output.** Secret data, passwords, tokens and sensitive-looking Helm
54
54
  parameters are redacted and counted. No flag reveals them.
55
55
  - **Never hangs.** Nothing streams or follows; waits are bounded; no prompt can
@@ -146,11 +146,13 @@ argocd-axi apps view <app> [--full]
146
146
  argocd-axi apps resources <app> [--problems | --orphaned]
147
147
  argocd-axi apps diff <app> [--revision <rev>] [--full]
148
148
  argocd-axi apps sync <app> [--confirm] [--prune] [--timeout <s>] --context <c>
149
+ argocd-axi apps auto-sync <app> <on|off> [--confirm] --context <c>
149
150
  argocd-axi apps history <app> [--limit <n>]
150
151
  argocd-axi apps manifests <app> [--source git|live] [--revision <rev>] [--kind <k>] [--full]
151
152
  argocd-axi apps logs <app> [--kind <k> --name <n>] [--tail 100] [--since 15m] [--filter <s>] [--full] [-p]
152
153
  argocd-axi apps wait <app> [--sync --health --operation] [--timeout 120]
153
154
  argocd-axi apps actions <app> [--kind <k>]
155
+ argocd-axi apps actions run <app> <action> --kind <k> --name <resource> [--confirm] --context <c>
154
156
  argocd-axi projects | projects view <p> | projects windows <p>
155
157
  argocd-axi appsets | appsets view <name> | appsets generate <file>
156
158
  argocd-axi repos | clusters
@@ -219,9 +221,7 @@ specific app.
219
221
  local-file and delete flags whatever a command passes, gives argocd no
220
222
  stdin, and enforces a timeout and an output cap. Tests fail if a new
221
223
  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.
224
+ - **Opt-in Application writes.** Sync, rollback, refresh, terminate-op, resource actions and automated-sync changes 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
225
  - **Redaction.** Kubernetes Secret `data`, `stringData` and their
226
226
  last-applied annotation, values under sensitive-looking keys, and env-var or
227
227
  Helm-parameter pairs with sensitive-looking names are replaced with
@@ -232,13 +232,12 @@ specific app.
232
232
  reach them.
233
233
  - **Writable contexts.** argocd-axi's own config file,
234
234
  `~/.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
- ```
235
+ holds the allowlist of contexts where Application writes may execute.
236
+ It defaults to none. To allow writes, a human runs
237
+ `argocd-axi ctx writable add` in a terminal to select a configured context,
238
+ or passes `<context>` directly; either path requires an explicit y/yes
239
+ confirmation. Non-interactive runs are refused. Use
240
+ `argocd-axi ctx writable remove <context>` to revoke access.
242
241
 
243
242
  - **Version cache.** The server version is checked at most once every 12
244
243
  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,
@@ -1,8 +1,9 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { setTimeout as delay } from "node:timers/promises";
2
3
  import { parseAllDocuments, stringify } from "yaml";
3
4
  import { argocdJson, runArgocd } from "../argocd.js";
4
5
  import { loadConfig } from "../config.js";
5
- import { evaluateWriteGate } from "../gate.js";
6
+ import { attributionArgs, evaluateWriteGate } from "../gate.js";
6
7
  import { CONTEXT_FLAG, getBool, getInt, getList, getString, parseArgs, parseDurationSeconds, validateName, } from "../args.js";
7
8
  import { CODES, axiError, mapArgocdFailure, validationError, } from "../errors.js";
8
9
  import { appName, appSources, attentionReasonPreview, attentionReasons, autoSyncSummary, destinationSummary, displayNames, formatAge, healthStatus, isOperationRunning, isResourceProblem, isUnhealthy, needsAttention, shortRevision, sortApps, syncRevision, syncStatus, plural, truncate, } from "../model.js";
@@ -550,7 +551,7 @@ export function parseDiffHeaders(text) {
550
551
  }
551
552
  return rows;
552
553
  }
553
- export async function appsDiff(args) {
554
+ export async function appsDiff(args, includeHelp = true) {
554
555
  const parsed = parseArgs(APPS_DIFF_SPEC, args);
555
556
  const name = validateName("app", parsed.positionals[0]);
556
557
  const revision = getString(parsed, "--revision");
@@ -584,9 +585,11 @@ export async function appsDiff(args) {
584
585
  count: `${plural(rows.length, "resource")} ${rows.length === 1 ? "differs" : "differ"}`,
585
586
  resources: redactor.deep(rows),
586
587
  }),
587
- ], redactor, [
588
- `Run \`argocd-axi apps diff ${name} --full${scope}\` to see the line-level diff`,
589
- ]);
588
+ ], redactor, includeHelp
589
+ ? [
590
+ `Run \`argocd-axi apps diff ${name} --full${scope}\` to see the line-level diff`,
591
+ ]
592
+ : []);
590
593
  }
591
594
  const argv = [name, "--exit-code=false"];
592
595
  if (revision !== undefined)
@@ -617,7 +620,7 @@ export async function appsDiff(args) {
617
620
  const lines = redactor.text(text).split("\n");
618
621
  blocks.push(renderTextBlock("diff", lines));
619
622
  }
620
- return finish(blocks, redactor, full
623
+ return finish(blocks, redactor, full || !includeHelp
621
624
  ? []
622
625
  : [
623
626
  `Run \`argocd-axi apps diff ${name}${revision ? ` --revision ${revision}` : ""} --full${scope}\` to see the line-level diff`,
@@ -1055,7 +1058,7 @@ export async function appsSync(args) {
1055
1058
  executeFlag: "--confirm",
1056
1059
  }, loadConfig().config);
1057
1060
  const options = { context: gate.context };
1058
- const diff = await appsDiff([name, "--context", gate.context]);
1061
+ const diff = await appsDiff([name, "--context", gate.context], false);
1059
1062
  const app = await getApp(name, options);
1060
1063
  const project = validateName("project", app.spec?.project ?? "default");
1061
1064
  const windows = (await argocdJson("proj windows list", [project, "-o", "json"], options)) ?? [];
@@ -1147,9 +1150,7 @@ export const APPS_ACTIONS_SPEC = {
1147
1150
  "argocd-axi apps actions guestbook",
1148
1151
  "argocd-axi apps actions guestbook --kind Rollout",
1149
1152
  ],
1150
- notes: [
1151
- "Running an action is planned for a later write tier; this command only lists them",
1152
- ],
1153
+ notes: ["List actions before running one with `apps actions run`"],
1153
1154
  };
1154
1155
  function pick(record, key) {
1155
1156
  const found = Object.keys(record).find((k) => k.toLowerCase() === key);
@@ -1157,7 +1158,7 @@ function pick(record, key) {
1157
1158
  }
1158
1159
  export async function appsActions(args) {
1159
1160
  if (args[0] === "run")
1160
- refuseActionRun();
1161
+ return appsActionRun(args.slice(1));
1161
1162
  const parsed = parseArgs(APPS_ACTIONS_SPEC, args);
1162
1163
  const name = validateName("app", parsed.positionals[0]);
1163
1164
  const argv = [name, "-o", "json"];
@@ -1183,10 +1184,342 @@ export async function appsActions(args) {
1183
1184
  }));
1184
1185
  return finish([encodeObject({ count: plural(rows.length, "action"), actions: rows })], undefined);
1185
1186
  }
1186
- function refuseActionRun() {
1187
- throw axiError("running resource actions is planned for the write tier, behind preview-by-default and the writable-contexts allowlist; not in v1 (see ROADMAP.md)", CODES.REFUSED, [
1188
- "A human can run one with the raw CLI: `argocd app actions run <app> <action> --kind <kind>`",
1189
- ]);
1187
+ const APPS_ACTION_RUN_SPEC = {
1188
+ path: "apps actions run",
1189
+ summary: "Preview and optionally run one action on one resource",
1190
+ positionals: [
1191
+ APP_ARG,
1192
+ { name: "action", required: true, description: "Listed resource action" },
1193
+ ],
1194
+ flags: [
1195
+ {
1196
+ name: "--kind",
1197
+ kind: "value",
1198
+ valueName: "kind",
1199
+ description: "Resource kind",
1200
+ },
1201
+ {
1202
+ name: "--name",
1203
+ kind: "value",
1204
+ valueName: "resource",
1205
+ description: "Resource name",
1206
+ },
1207
+ {
1208
+ name: "--group",
1209
+ kind: "value",
1210
+ valueName: "group",
1211
+ description: "Resource API group when needed to disambiguate",
1212
+ },
1213
+ {
1214
+ name: "--namespace",
1215
+ kind: "value",
1216
+ valueName: "namespace",
1217
+ description: "Resource namespace when needed to disambiguate",
1218
+ },
1219
+ { name: "--confirm", kind: "bool", description: "Execute after preview" },
1220
+ {
1221
+ name: "--timeout",
1222
+ kind: "value",
1223
+ valueName: "s",
1224
+ description: "Maximum wait in seconds (default 120)",
1225
+ },
1226
+ CONTEXT_FLAG,
1227
+ ],
1228
+ examples: [
1229
+ "argocd-axi apps actions run guestbook restart --kind Deployment --name guestbook --context staging",
1230
+ ],
1231
+ };
1232
+ async function appsActionRun(args) {
1233
+ const parsed = parseArgs(APPS_ACTION_RUN_SPEC, args);
1234
+ const appNameArg = validateName("app", parsed.positionals[0]);
1235
+ const action = validateName("action", parsed.positionals[1]);
1236
+ const kind = getString(parsed, "--kind");
1237
+ const resourceName = getString(parsed, "--name");
1238
+ const group = getString(parsed, "--group");
1239
+ const namespace = getString(parsed, "--namespace");
1240
+ const timeout = getInt(parsed, "--timeout", WRITE_TIMEOUT);
1241
+ if (!kind || !resourceName)
1242
+ throw validationError("apps actions run needs --kind and --name");
1243
+ const context = getString(parsed, "--context"), confirm = getBool(parsed, "--confirm");
1244
+ const gate = evaluateWriteGate({
1245
+ command: "apps actions run",
1246
+ context,
1247
+ execute: confirm,
1248
+ executeFlag: "--confirm",
1249
+ }, loadConfig().config);
1250
+ const options = { context: gate.context };
1251
+ const app = await getApp(appNameArg, options);
1252
+ const resources = (app.status?.resources ?? []).filter((r) => r.kind === kind &&
1253
+ r.name === resourceName &&
1254
+ (group === undefined || r.group === group) &&
1255
+ (namespace === undefined || r.namespace === namespace));
1256
+ const resource = resources[0];
1257
+ if (!resource)
1258
+ throw axiError(`REFUSED: ${kind}/${resourceName} is not managed by ${appNameArg}`, CODES.REFUSED);
1259
+ if (resources.length > 1)
1260
+ throw axiError(`REFUSED: ${kind}/${resourceName} is ambiguous; candidates: ${resources
1261
+ .map((r) => `${r.group ?? "core"}/${r.kind} ${r.namespace ?? "_cluster"}/${r.name}`)
1262
+ .join(", ")}. Specify --group and/or --namespace`, CODES.REFUSED);
1263
+ const actions = await argocdJson("app actions list", [appNameArg, "--kind", kind, "-o", "json"], options);
1264
+ const matchingActions = actions.filter((entry) => String(pick(entry, "name") ?? "") === resourceName &&
1265
+ String(pick(entry, "group") ?? "") === (resource.group ?? "") &&
1266
+ String(pick(entry, "kind") ?? "") === kind &&
1267
+ String(pick(entry, "action") ?? "") === action);
1268
+ const namespacedActions = matchingActions.filter((entry) => pick(entry, "namespace") !== undefined);
1269
+ const unnamespacedAction = matchingActions.length === 1 ? matchingActions[0] : undefined;
1270
+ const actionAvailable = namespacedActions.length
1271
+ ? namespacedActions.some((entry) => pick(entry, "namespace") === resource.namespace &&
1272
+ pick(entry, "disabled") !== true)
1273
+ : unnamespacedAction !== undefined &&
1274
+ pick(unnamespacedAction, "disabled") !== true;
1275
+ if (!actionAvailable)
1276
+ throw axiError(`REFUSED: action ${action} is unavailable for ${kind}/${resourceName}`, CODES.REFUSED);
1277
+ const blocks = [
1278
+ encodeObject({ action, app: appNameArg, kind, resource: resourceName }),
1279
+ ];
1280
+ if (!confirm)
1281
+ return finish(blocks, undefined, [
1282
+ "No changes made; add --confirm to run the action",
1283
+ ...(gate.mode === "preview" ? gate.help : []),
1284
+ ]);
1285
+ await checkWritePermission(`action/${resource.group ?? ""}/${kind}/${action}`, app, appNameArg, options);
1286
+ await runApplicationWrite("app actions run", [
1287
+ appNameArg,
1288
+ action,
1289
+ "--kind",
1290
+ kind,
1291
+ "--resource-name",
1292
+ resourceName,
1293
+ ...(resource.group ? ["--group", resource.group] : []),
1294
+ ...(resource.namespace ? ["--namespace", resource.namespace] : []),
1295
+ ...attributionArgs(),
1296
+ ], options, appNameArg);
1297
+ const current = await waitForAppState(appNameArg, timeout, options, (fresh) => fresh.status?.resources?.some((r) => r.group === resource.group &&
1298
+ r.kind === resource.kind &&
1299
+ r.namespace === resource.namespace &&
1300
+ r.name === resource.name &&
1301
+ r.health?.status === "Healthy") ?? false);
1302
+ const state = current.status?.resources?.find((r) => r.group === resource.group &&
1303
+ r.kind === resource.kind &&
1304
+ r.namespace === resource.namespace &&
1305
+ r.name === resource.name)?.health?.status ?? "unknown";
1306
+ return finish([...blocks, encodeObject({ result: "submitted", state })], undefined);
1307
+ }
1308
+ // ---------------------------------------------------------------- app writes
1309
+ function writeSpec(path, summary, positionals) {
1310
+ return {
1311
+ path,
1312
+ summary,
1313
+ positionals,
1314
+ flags: [
1315
+ { name: "--confirm", kind: "bool", description: "Execute after preview" },
1316
+ ...(path === "apps refresh"
1317
+ ? []
1318
+ : [
1319
+ {
1320
+ name: "--timeout",
1321
+ kind: "value",
1322
+ valueName: "s",
1323
+ description: "Maximum wait in seconds (default 120)",
1324
+ },
1325
+ ]),
1326
+ ...(path === "apps refresh"
1327
+ ? [
1328
+ {
1329
+ name: "--hard",
1330
+ kind: "bool",
1331
+ description: "Hard refresh, bypassing the cache",
1332
+ },
1333
+ ]
1334
+ : []),
1335
+ CONTEXT_FLAG,
1336
+ ],
1337
+ examples: [
1338
+ `argocd-axi ${path} ${path === "apps rollback" ? "guestbook 3" : "guestbook"} --context staging`,
1339
+ ],
1340
+ };
1341
+ }
1342
+ const WRITE_TIMEOUT = { default: 120, min: 1, max: 3600 };
1343
+ async function checkWritePermission(action, app, name, options) {
1344
+ const permission = await runArgocd("account can-i", [action, "applications", `${app.spec?.project ?? "default"}/${name}`], { ...options, allowFailure: true });
1345
+ if (permission.exitCode !== 0 || !/^yes\s*$/i.test(permission.stdout.trim()))
1346
+ throw axiError(`REFUSED: Argo CD RBAC denies ${action} for ${name}`, CODES.REFUSED);
1347
+ }
1348
+ async function runApplicationWrite(op, args, options, name) {
1349
+ try {
1350
+ await runArgocd(op, args, options);
1351
+ }
1352
+ catch (error) {
1353
+ if (error instanceof AxiError && error.code === CODES.FORBIDDEN)
1354
+ throw axiError(`REFUSED: Argo CD RBAC denied ${op.slice(4)} for ${name}`, CODES.REFUSED);
1355
+ throw error;
1356
+ }
1357
+ }
1358
+ function writeOutcome(phase, timedOut, completed) {
1359
+ if (phase === "Failed" || phase === "Error")
1360
+ return "failed";
1361
+ if (phase === "Succeeded")
1362
+ return completed;
1363
+ if (timedOut)
1364
+ return "waiting";
1365
+ return phase ?? "unknown";
1366
+ }
1367
+ async function waitForAppState(name, timeout, options, settled) {
1368
+ const deadline = Date.now() + timeout * 1000;
1369
+ let app = await getApp(name, options);
1370
+ while (!settled(app) && Date.now() < deadline) {
1371
+ await delay(Math.min(1000, Math.max(0, deadline - Date.now())));
1372
+ app = await getApp(name, options);
1373
+ }
1374
+ return app;
1375
+ }
1376
+ async function waitForWrite(name, timeout, options) {
1377
+ try {
1378
+ const result = await runArgocd("app wait", [name, "--operation", "--timeout", String(timeout)], { ...options, allowFailure: true, timeoutMs: (timeout + 10) * 1000 });
1379
+ if (result.exitCode !== 0) {
1380
+ const failure = mapArgocdFailure(result.stderr || result.stdout);
1381
+ if (!/timed? out|timeout/i.test(failure.message))
1382
+ throw failure;
1383
+ return { app: await getApp(name, options), timedOut: true };
1384
+ }
1385
+ return { app: await getApp(name, options), timedOut: false };
1386
+ }
1387
+ catch (error) {
1388
+ if (!(error instanceof AxiError) || error.code !== CODES.TIMEOUT)
1389
+ throw error;
1390
+ return { app: await getApp(name, options), timedOut: true };
1391
+ }
1392
+ }
1393
+ const APPS_ROLLBACK_SPEC = writeSpec("apps rollback", "Preview and optionally roll back one Application", [
1394
+ APP_ARG,
1395
+ {
1396
+ name: "history-id",
1397
+ required: true,
1398
+ description: "Deployment history id",
1399
+ },
1400
+ ]);
1401
+ export async function appsRollback(args) {
1402
+ const parsed = parseArgs(APPS_ROLLBACK_SPEC, args);
1403
+ const name = validateName("app", parsed.positionals[0]);
1404
+ const historyId = parsed.positionals[1];
1405
+ if (!/^\d+$/.test(historyId))
1406
+ throw validationError("history-id must be a non-negative decimal integer");
1407
+ const id = Number(historyId);
1408
+ if (!Number.isSafeInteger(id) || id < 0)
1409
+ throw validationError("history-id must be a non-negative integer");
1410
+ const context = getString(parsed, "--context"), confirm = getBool(parsed, "--confirm"), timeout = getInt(parsed, "--timeout", WRITE_TIMEOUT);
1411
+ const gate = evaluateWriteGate({
1412
+ command: "apps rollback",
1413
+ context,
1414
+ execute: confirm,
1415
+ executeFlag: "--confirm",
1416
+ }, loadConfig().config);
1417
+ const options = { context: gate.context }, app = await getApp(name, options);
1418
+ const history = app.status?.history?.find((entry) => entry.id === id);
1419
+ if (!history)
1420
+ throw axiError(`REFUSED: history id ${id} not found for ${name}`, CODES.REFUSED);
1421
+ if (autoSyncSummary(app) !== "manual")
1422
+ throw axiError(`REFUSED: disable auto-sync before rolling back ${name}`, CODES.REFUSED);
1423
+ const target = history.revisions?.join(", ") ?? history.revision ?? "unknown";
1424
+ const previewBlocks = [
1425
+ encodeObject({
1426
+ rollback: name,
1427
+ currentRevision: syncRevision(app),
1428
+ targetHistoryId: id,
1429
+ targetRevision: target,
1430
+ }),
1431
+ ];
1432
+ if (!confirm)
1433
+ return finish(previewBlocks, undefined, [
1434
+ "No changes made; add --confirm to roll back",
1435
+ ...(gate.mode === "preview" ? gate.help : []),
1436
+ ]);
1437
+ await checkWritePermission("sync", app, name, options);
1438
+ await runApplicationWrite("app rollback", [name, String(id), "--info", "initiated-by=argocd-axi"], options, name);
1439
+ const current = await waitForWrite(name, timeout, options);
1440
+ const outcome = writeOutcome(current.app.status?.operationState?.phase, current.timedOut, "submitted");
1441
+ return finish([
1442
+ ...previewBlocks,
1443
+ encodeObject({
1444
+ rollback: outcome,
1445
+ historyId: id,
1446
+ phase: current.app.status?.operationState?.phase,
1447
+ operation: current.app.status?.operationState,
1448
+ }),
1449
+ ], undefined, outcome === "waiting" ? ["Wait again with `apps wait`"] : []);
1450
+ }
1451
+ const APPS_REFRESH_SPEC = writeSpec("apps refresh", "Preview and optionally refresh one Application", [APP_ARG]);
1452
+ export async function appsRefresh(args) {
1453
+ const parsed = parseArgs(APPS_REFRESH_SPEC, args);
1454
+ const name = validateName("app", parsed.positionals[0]);
1455
+ const context = getString(parsed, "--context"), confirm = getBool(parsed, "--confirm"), hard = getBool(parsed, "--hard");
1456
+ const gate = evaluateWriteGate({
1457
+ command: "apps refresh",
1458
+ context,
1459
+ execute: confirm,
1460
+ executeFlag: "--confirm",
1461
+ }, loadConfig().config);
1462
+ const options = { context: gate.context }, app = await getApp(name, options);
1463
+ const blocks = [
1464
+ encodeObject({
1465
+ refresh: name,
1466
+ mode: hard ? "hard" : "normal",
1467
+ reRead: hard ? "live state and source caches" : "live state",
1468
+ }),
1469
+ ];
1470
+ if (!confirm)
1471
+ return finish(blocks, undefined, [
1472
+ "No changes made; add --confirm to refresh",
1473
+ ...(gate.mode === "preview" ? gate.help : []),
1474
+ ]);
1475
+ await checkWritePermission("update", app, name, options);
1476
+ await runApplicationWrite("app refresh", [name, hard ? "--hard-refresh" : "--refresh"], options, name);
1477
+ return finish([...blocks, encodeObject({ refresh: "submitted" })], undefined);
1478
+ }
1479
+ const APPS_TERMINATE_SPEC = writeSpec("apps terminate-op", "Preview and optionally terminate one running operation", [APP_ARG]);
1480
+ export async function appsTerminateOp(args) {
1481
+ const parsed = parseArgs(APPS_TERMINATE_SPEC, args);
1482
+ const name = validateName("app", parsed.positionals[0]);
1483
+ const context = getString(parsed, "--context"), confirm = getBool(parsed, "--confirm"), timeout = getInt(parsed, "--timeout", WRITE_TIMEOUT);
1484
+ const gate = evaluateWriteGate({
1485
+ command: "apps terminate-op",
1486
+ context,
1487
+ execute: confirm,
1488
+ executeFlag: "--confirm",
1489
+ }, loadConfig().config);
1490
+ const options = { context: gate.context }, app = await getApp(name, options), phase = app.status?.operationState?.phase;
1491
+ if (!isOperationRunning(app))
1492
+ return finish([
1493
+ encodeObject({
1494
+ terminateOperation: name,
1495
+ status: "no operation running",
1496
+ }),
1497
+ ], undefined);
1498
+ const blocks = [
1499
+ encodeObject({
1500
+ terminateOperation: name,
1501
+ phase,
1502
+ startedAt: app.status?.operationState?.startedAt,
1503
+ message: app.status?.operationState?.message,
1504
+ }),
1505
+ ];
1506
+ if (!confirm)
1507
+ return finish(blocks, undefined, [
1508
+ "No changes made; add --confirm to terminate the operation",
1509
+ ...(gate.mode === "preview" ? gate.help : []),
1510
+ ]);
1511
+ await checkWritePermission("update", app, name, options);
1512
+ await runApplicationWrite("app terminate-op", [name], options, name);
1513
+ const current = await waitForWrite(name, timeout, options);
1514
+ const outcome = writeOutcome(current.app.status?.operationState?.phase, current.timedOut, "submitted");
1515
+ return finish([
1516
+ ...blocks,
1517
+ encodeObject({
1518
+ terminateOperation: outcome,
1519
+ phase: current.app.status?.operationState?.phase,
1520
+ operation: current.app.status?.operationState,
1521
+ }),
1522
+ ], undefined, outcome === "waiting" ? ["Wait again with `apps wait`"] : []);
1190
1523
  }
1191
1524
  // ---------------------------------------------------------------- dispatcher
1192
1525
  const SUBCOMMANDS = {
@@ -1199,7 +1532,65 @@ const SUBCOMMANDS = {
1199
1532
  wait: { spec: APPS_WAIT_SPEC, run: appsWait },
1200
1533
  actions: { spec: APPS_ACTIONS_SPEC, run: appsActions },
1201
1534
  sync: { spec: APPS_SYNC_SPEC, run: appsSync },
1535
+ rollback: { spec: APPS_ROLLBACK_SPEC, run: appsRollback },
1536
+ refresh: { spec: APPS_REFRESH_SPEC, run: appsRefresh },
1537
+ "terminate-op": { spec: APPS_TERMINATE_SPEC, run: appsTerminateOp },
1538
+ };
1539
+ const APPS_AUTO_SYNC_SPEC = {
1540
+ path: "apps auto-sync",
1541
+ summary: "Preview and optionally enable or disable automated sync",
1542
+ positionals: [
1543
+ APP_ARG,
1544
+ { name: "mode", required: true, description: "on or off" },
1545
+ ],
1546
+ flags: [
1547
+ { name: "--confirm", kind: "bool", description: "Execute after preview" },
1548
+ {
1549
+ name: "--timeout",
1550
+ kind: "value",
1551
+ valueName: "s",
1552
+ description: "Maximum wait in seconds (default 120)",
1553
+ },
1554
+ CONTEXT_FLAG,
1555
+ ],
1556
+ examples: ["argocd-axi apps auto-sync guestbook on --context staging"],
1202
1557
  };
1558
+ async function appsAutoSync(args) {
1559
+ const parsed = parseArgs(APPS_AUTO_SYNC_SPEC, args);
1560
+ const name = validateName("app", parsed.positionals[0]);
1561
+ const mode = parsed.positionals[1];
1562
+ if (mode !== "on" && mode !== "off")
1563
+ throw validationError("mode must be on or off");
1564
+ const context = getString(parsed, "--context"), confirm = getBool(parsed, "--confirm"), timeout = getInt(parsed, "--timeout", WRITE_TIMEOUT);
1565
+ const gate = evaluateWriteGate({
1566
+ command: "apps auto-sync",
1567
+ context,
1568
+ execute: confirm,
1569
+ executeFlag: "--confirm",
1570
+ }, loadConfig().config);
1571
+ const options = { context: gate.context }, app = await getApp(name, options);
1572
+ const current = autoSyncSummary(app), blocks = [encodeObject({ autoSync: name, current, target: mode })];
1573
+ if (!confirm)
1574
+ return finish(blocks, undefined, [
1575
+ "No changes made; add --confirm to change automated sync",
1576
+ ...(gate.mode === "preview" ? gate.help : []),
1577
+ ]);
1578
+ await checkWritePermission("update", app, name, options);
1579
+ await runApplicationWrite("app set", [
1580
+ name,
1581
+ "--sync-policy",
1582
+ mode === "on" ? "automated" : "none",
1583
+ ...attributionArgs(),
1584
+ ], options, name);
1585
+ const observed = await waitForAppState(name, timeout, options, (fresh) => mode === "on"
1586
+ ? autoSyncSummary(fresh).startsWith("automated")
1587
+ : autoSyncSummary(fresh) === "manual");
1588
+ return finish([
1589
+ ...blocks,
1590
+ encodeObject({ result: "submitted", state: autoSyncSummary(observed) }),
1591
+ ], undefined);
1592
+ }
1593
+ SUBCOMMANDS["auto-sync"] = { spec: APPS_AUTO_SYNC_SPEC, run: appsAutoSync };
1203
1594
  export const APPS_SPECS = [
1204
1595
  APPS_LIST_SPEC,
1205
1596
  ...Object.values(SUBCOMMANDS).map((s) => s.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,178 @@ 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: false,
154
+ description: "Configured Argo CD context",
155
+ },
156
+ ],
157
+ flags: [],
158
+ examples: [
159
+ "argocd-axi ctx writable add",
160
+ "argocd-axi ctx writable add staging",
161
+ ],
162
+ };
163
+ const WRITABLE_REMOVE_SPEC = {
164
+ path: "ctx writable remove",
165
+ summary: "Remove write access from a context",
166
+ positionals: [
167
+ {
168
+ name: "context",
169
+ required: true,
170
+ description: "Configured Argo CD context",
171
+ },
172
+ ],
173
+ flags: [],
174
+ examples: ["argocd-axi ctx writable remove staging"],
175
+ };
176
+ const WRITABLE_LIST_SPEC = {
177
+ path: "ctx writable list",
178
+ summary: "List contexts allowed to perform writes",
179
+ positionals: [],
180
+ flags: [],
181
+ examples: ["argocd-axi ctx writable list"],
182
+ };
183
+ export async function readConfirmation(input, output, prompt) {
184
+ const rl = createInterface({ input, output });
185
+ const closed = new Promise((resolve) => rl.once("close", () => resolve("")));
186
+ try {
187
+ return await Promise.race([rl.question(prompt), closed]);
188
+ }
189
+ finally {
190
+ rl.close();
191
+ }
192
+ }
193
+ export async function confirmWritableContext(name, server, input, output) {
194
+ const answer = await readConfirmation(input, output, `Allow argocd-axi to sync, rollback, refresh, and terminate operations on context ${name} (${server})? [y/N] `);
195
+ return /^y(?:es)?$/i.test(answer.trim());
196
+ }
197
+ export async function contextDetails(contexts, lookup = async (name) => (await argocdJson("account get-user-info", ["-o", "json"], { context: name, timeoutMs: 1_500 })).username) {
198
+ const result = [];
199
+ for (let offset = 0; offset < contexts.length; offset += 3) {
200
+ const batch = await Promise.all(contexts.slice(offset, offset + 3).map(async (context) => ({
201
+ ...context,
202
+ username: (await lookup(context.name).catch(() => undefined)) ?? "unknown",
203
+ })));
204
+ result.push(...batch);
205
+ }
206
+ return result;
207
+ }
208
+ export function addWritableContextIfConfirmed(name, confirmed) {
209
+ return confirmed ? addWritableContext(name) : false;
210
+ }
211
+ export async function chooseContext(contexts, writable, input, output) {
212
+ output.write(contexts
213
+ .map((context, index) => `${index + 1}. ${context.name} - ${context.server} - ${context.username}${writable.includes(context.name) ? " (already writable)" : ""}`)
214
+ .join("\n") + "\n");
215
+ const selected = await readConfirmation(input, output, "Select a context by number: ");
216
+ const index = Number(selected);
217
+ const context = Number.isInteger(index) && index >= 1 ? contexts[index - 1] : undefined;
218
+ if (!context)
219
+ throw axiError("invalid context selection; no changes made", CODES.REFUSED);
220
+ return context.name;
221
+ }
222
+ async function writableAdd(args) {
223
+ const parsed = parseArgs(WRITABLE_ADD_SPEC, args);
224
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
225
+ throw axiError("writable context changes require an interactive terminal; run this command in a terminal", CODES.REFUSED);
226
+ const listing = await listContexts();
227
+ if (listing.notInstalled)
228
+ throw listing.notInstalled;
229
+ if (listing.unavailable) {
230
+ throw axiError(`cannot list Argo CD contexts: ${listing.unavailable}`, CODES.NO_CONTEXT, LOGIN_HELP);
231
+ }
232
+ const writable = loadConfig().config.writableContexts;
233
+ let name = parsed.positionals[0]
234
+ ? validateContextName(parsed.positionals[0])
235
+ : undefined;
236
+ let username;
237
+ if (name === undefined) {
238
+ if (listing.contexts.length === 0)
239
+ throw axiError("no configured Argo CD contexts to select", CODES.NO_CONTEXT);
240
+ const details = await contextDetails(listing.contexts);
241
+ name = await chooseContext(details, writable, process.stdin, process.stdout);
242
+ username = details.find((context) => context.name === name)?.username;
243
+ }
244
+ const selectedName = name;
245
+ const match = listing.contexts.find((context) => context.name === selectedName);
246
+ if (!match)
247
+ throw axiError(`Argo CD context ${name} is not configured`, CODES.CONTEXT_NOT_FOUND);
248
+ if (writable.includes(selectedName))
249
+ return finish([
250
+ encodeObject({
251
+ writableContext: `${selectedName} is already writable (no change)`,
252
+ }),
253
+ ], undefined);
254
+ username ??= (await argocdJson("account get-user-info", ["-o", "json"], { context: selectedName })).username;
255
+ process.stdout.write(`Context: ${selectedName}\nServer: ${match.server}\nUser: ${username ?? "unknown"}\n`);
256
+ const confirmed = await confirmWritableContext(selectedName, match.server, process.stdin, process.stdout);
257
+ const changed = addWritableContextIfConfirmed(selectedName, confirmed);
258
+ if (!confirmed)
259
+ return finish([
260
+ encodeObject({
261
+ writableContext: `${selectedName} remains read-only (no change)`,
262
+ }),
263
+ ], undefined);
264
+ return finish([
265
+ encodeObject({
266
+ writableContext: changed
267
+ ? `added ${selectedName}`
268
+ : `${selectedName} is already writable (no change)`,
269
+ }),
270
+ ], undefined);
271
+ }
272
+ async function writableRemove(args) {
273
+ const parsed = parseArgs(WRITABLE_REMOVE_SPEC, args);
274
+ const name = validateContextName(parsed.positionals[0]);
275
+ const changed = removeWritableContext(name);
276
+ return finish([
277
+ encodeObject({
278
+ writableContext: changed
279
+ ? `removed ${name}`
280
+ : `${name} is not in writable-contexts (no change)`,
281
+ }),
282
+ ], undefined);
283
+ }
284
+ async function writableList(args) {
285
+ parseArgs(WRITABLE_LIST_SPEC, args);
286
+ return finish([encodeObject({ writableContexts: loadConfig().config.writableContexts })], undefined);
287
+ }
288
+ const WRITABLE_SUBCOMMANDS = {
289
+ add: { spec: WRITABLE_ADD_SPEC, run: writableAdd },
290
+ remove: { spec: WRITABLE_REMOVE_SPEC, run: writableRemove },
291
+ list: { spec: WRITABLE_LIST_SPEC, run: writableList },
292
+ };
138
293
  const SUBCOMMANDS = {
139
294
  use: { spec: CTX_USE_SPEC, run: ctxUse },
295
+ writable: {
296
+ spec: WRITABLE_SPEC,
297
+ run: (args) => dispatch("ctx writable", args, WRITABLE_SUBCOMMANDS, {
298
+ spec: WRITABLE_SPEC,
299
+ run: async () => {
300
+ throw validationError("expected list, add, or remove for `ctx writable`");
301
+ },
302
+ }),
303
+ },
140
304
  };
141
305
  export const CTX_SPECS = [CTX_SPEC, CTX_USE_SPEC];
142
306
  export async function ctxCommand(args) {
143
307
  const first = args[0];
144
- if (first !== undefined && !first.startsWith("-") && first !== "use") {
308
+ if (first !== undefined &&
309
+ !first.startsWith("-") &&
310
+ first !== "use" &&
311
+ first !== "writable") {
145
312
  throw validationError(`unexpected argument ${first} for \`ctx\``, [
146
313
  `To switch contexts, run \`argocd-axi ctx use ${first}\``,
147
314
  `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,11 +13,16 @@ 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",
19
+ "apps auto-sync <app> <on|off> [--confirm]: preview, then optionally change automated sync",
20
+ "apps actions run <app> <action> --kind <kind> --name <resource> [--confirm]: preview, then optionally run one listed resource action",
16
21
  "apps history <app>: deployments, newest first",
17
22
  "apps manifests <app> [--source git|live] [--kind <k>] [--full]: rendered manifests, Secret data redacted",
18
23
  "apps logs <app> [--kind --name --tail --since --filter -p]: recent pod logs, never follows",
19
24
  "apps wait <app> [--sync --health --operation] [--timeout <s>]: bounded wait, returns the state either way",
20
- "apps actions <app>: resource actions available (listing only)",
25
+ "apps actions <app>: list available resource actions",
21
26
  "projects | projects view <p> | projects windows <p>: projects, their rules, and whether sync is allowed now",
22
27
  "appsets | appsets view <name> | appsets generate <file>: ApplicationSets and what a spec would generate",
23
28
  "repos | clusters: repositories and destination clusters with connection status",
@@ -39,7 +44,7 @@ export const TOP_HELP = `${renderBlocks([
39
44
  "argocd-axi apps diff payments-api",
40
45
  ]),
41
46
  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",
47
+ "Writes on writable contexts require --confirm: apps sync, rollback, refresh, terminate-op, auto-sync and actions run; sync never prunes unless --prune is given",
43
48
  "Output is TOON on stdout, errors included; exit 0 = success (an unhealthy app is data, not an error), 1 = error, 2 = usage",
44
49
  "Secret-looking values are redacted and counted; no flag reveals them",
45
50
  "Uses your existing argocd CLI session or ARGOCD_AUTH_TOKEN; never logs in, never reads the argocd config file",
@@ -32,6 +32,36 @@ 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
+ },
53
+ "app actions run": {
54
+ argv: ["app", "actions", "run"],
55
+ tier: "write",
56
+ network: true,
57
+ timeoutMs: READ_TIMEOUT_MS,
58
+ },
59
+ "app set": {
60
+ argv: ["app", "set"],
61
+ tier: "write",
62
+ network: true,
63
+ timeoutMs: READ_TIMEOUT_MS,
64
+ },
35
65
  "app resources": {
36
66
  argv: ["app", "resources"],
37
67
  tier: "read",
@@ -172,41 +202,11 @@ export const FORBIDDEN_ARGS = [
172
202
  "--port-forward",
173
203
  "--prompts-enabled",
174
204
  ];
175
- const PLANNED = "planned for the write tier, behind preview-by-default and the writable-contexts allowlist; not in v1 (see ROADMAP.md)";
176
205
  /**
177
206
  * Writes the design names for the next tier (decision A2): known verbs that
178
207
  * answer REFUSED with the raw argocd route a human can take today.
179
208
  */
180
- 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
- "apps action": {
203
- reason: `running resource actions is ${PLANNED}`,
204
- help: [
205
- "List available actions with `argocd-axi apps actions <app>`",
206
- "A human can run one with the raw CLI: `argocd app actions run <app> <action> --kind <kind>`",
207
- ],
208
- },
209
- };
209
+ export const PLANNED_WRITES = {};
210
210
  const HUMAN_ONLY = "A human runs this with the raw argocd CLI";
211
211
  /**
212
212
  * Known verbs that argocd-axi never wraps (design decisions S7 and A2), each
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\`. See the README's writable-context instructions before enabling writes.
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.6"; // 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.6",
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`. See the README's writable-context instructions before enabling writes.
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[25]:
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,11 +46,16 @@ 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
52
+ apps auto-sync <app> <on|off> [--confirm]: preview, then optionally change automated sync
53
+ apps actions run <app> <action> --kind <kind> --name <resource> [--confirm]: preview, then optionally run one listed resource action
49
54
  apps history <app>: deployments, newest first
50
55
  apps manifests <app> [--source git|live] [--kind <k>] [--full]: rendered manifests, Secret data redacted
51
56
  apps logs <app> [--kind --name --tail --since --filter -p]: recent pod logs, never follows
52
57
  apps wait <app> [--sync --health --operation] [--timeout <s>]: bounded wait, returns the state either way
53
- apps actions <app>: resource actions available (listing only)
58
+ apps actions <app>: list available resource actions
54
59
  projects | projects view <p> | projects windows <p>: projects, their rules, and whether sync is allowed now
55
60
  appsets | appsets view <name> | appsets generate <file>: ApplicationSets and what a spec would generate
56
61
  repos | clusters: repositories and destination clusters with connection status