@trigguard/cli 0.1.1 → 0.1.3

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.
@@ -0,0 +1,4 @@
1
+ export declare function runAuthList(args: string[]): Promise<void>;
2
+ export declare function runAuthRevoke(args: string[]): Promise<void>;
3
+ export declare function runAuthRotate(args: string[]): Promise<void>;
4
+ export declare function runAuth(args: string[]): Promise<void>;
@@ -0,0 +1,227 @@
1
+ import readline from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import { hasFlag } from "../tg/args.js";
4
+ import { formatControlPlaneAuthError, formatCorruptConfigError, formatNotLoggedInError, } from "../cp/errors.js";
5
+ import { formatKeyField, listApiKeys, revokeApiKeyById, rotateApiKey, } from "../cp/apiKeys.js";
6
+ import { loadConfig, saveConfig, configPath, configFileCorrupted } from "../cp/config.js";
7
+ import { envOverridesConfigApiKey } from "../cp/credentials.js";
8
+ import { resolveActiveApiKeyRecord } from "../cp/sessionContext.js";
9
+ import { resolveSessionSnapshot } from "./session.js";
10
+ function flagValue(args, name) {
11
+ const i = args.indexOf(name);
12
+ if (i === -1)
13
+ return undefined;
14
+ const next = args[i + 1];
15
+ if (next === undefined || next.startsWith("-"))
16
+ return undefined;
17
+ return next;
18
+ }
19
+ async function requireSession() {
20
+ if (configFileCorrupted()) {
21
+ throw new Error(formatCorruptConfigError(configPath()));
22
+ }
23
+ const config = loadConfig();
24
+ const snap = await resolveSessionSnapshot();
25
+ if (!snap.authenticated || !config.sessionToken) {
26
+ throw new Error(formatNotLoggedInError());
27
+ }
28
+ const orgId = snap.activeOrg?.orgId;
29
+ if (!orgId) {
30
+ throw new Error([
31
+ "No active workspace.",
32
+ "",
33
+ "Verify your email in the console, then run:",
34
+ " tg login",
35
+ ].join("\n"));
36
+ }
37
+ return { config, orgId, orgName: snap.activeOrg?.name ?? orgId };
38
+ }
39
+ function keyRecordForList(key, currentKeyId) {
40
+ return {
41
+ keyId: key.keyId,
42
+ displayName: formatKeyField(key.displayName),
43
+ workspace: key.orgId,
44
+ created: formatKeyField(key.createdAt),
45
+ lastUsed: formatKeyField(key.lastUsedAt ?? undefined),
46
+ scopes: key.scopes?.length ? key.scopes : ["Not reported"],
47
+ status: formatKeyField(key.status ?? (key.revoked ? "revoked" : "active")),
48
+ currentMachine: currentKeyId === key.keyId,
49
+ expires: formatKeyField(key.expiresAt ?? undefined),
50
+ lastRotatedFrom: formatKeyField(key.rotatedFromKeyId ?? undefined),
51
+ };
52
+ }
53
+ export async function runAuthList(args) {
54
+ const json = hasFlag(args, "--json");
55
+ const { config, orgId } = await requireSession();
56
+ try {
57
+ const keys = await listApiKeys(config, orgId);
58
+ const activeKeyId = resolveActiveApiKeyRecord(keys, config)?.keyId ?? config.apiKeyId;
59
+ if (json) {
60
+ console.log(JSON.stringify({
61
+ orgId,
62
+ keys: keys.map((k) => keyRecordForList(k, activeKeyId)),
63
+ }, null, 2));
64
+ return;
65
+ }
66
+ if (!keys.length) {
67
+ console.log("No API keys in this workspace.");
68
+ return;
69
+ }
70
+ for (const key of keys) {
71
+ const current = activeKeyId === key.keyId ? " (this machine)" : "";
72
+ console.log(`- ${formatKeyField(key.displayName)}${current}`);
73
+ console.log(` key id: ${key.keyId}`);
74
+ console.log(` status: ${formatKeyField(key.status ?? (key.revoked ? "revoked" : "active"))}`);
75
+ console.log(` created: ${formatKeyField(key.createdAt)}`);
76
+ console.log(` last used: ${formatKeyField(key.lastUsedAt ?? undefined)}`);
77
+ console.log(` scopes: ${key.scopes?.join(", ") || "Not reported"}`);
78
+ }
79
+ }
80
+ catch (e) {
81
+ throw new Error(formatControlPlaneAuthError(e, "API key list"));
82
+ }
83
+ }
84
+ async function confirmPrompt(question) {
85
+ const rl = readline.createInterface({ input, output });
86
+ try {
87
+ const answer = (await rl.question(question)).trim().toLowerCase();
88
+ return answer === "y" || answer === "yes";
89
+ }
90
+ finally {
91
+ rl.close();
92
+ }
93
+ }
94
+ export async function runAuthRevoke(args) {
95
+ const json = hasFlag(args, "--json");
96
+ const yes = hasFlag(args, "--yes");
97
+ const keyId = flagValue(args, "--key") ?? flagValue(args, "--id");
98
+ const { config } = await requireSession();
99
+ const targetId = keyId ?? config.apiKeyId;
100
+ if (!targetId) {
101
+ throw new Error([
102
+ "No API key specified.",
103
+ "",
104
+ "Run:",
105
+ " tg auth list",
106
+ " tg auth revoke --key <key-id>",
107
+ ].join("\n"));
108
+ }
109
+ if (!yes) {
110
+ if (input.isTTY) {
111
+ const ok = await confirmPrompt(`Revoke API key ${targetId}? [y/N] `);
112
+ if (!ok) {
113
+ console.log("Revoke cancelled.");
114
+ return;
115
+ }
116
+ }
117
+ else {
118
+ throw new Error([
119
+ "Revoke requires confirmation in non-interactive mode.",
120
+ "",
121
+ "TrigGuard will not revoke API keys without an explicit --yes flag.",
122
+ "",
123
+ "Run:",
124
+ ` tg auth revoke --key ${targetId} --yes`,
125
+ ].join("\n"));
126
+ }
127
+ }
128
+ try {
129
+ await revokeApiKeyById(config, targetId);
130
+ }
131
+ catch (e) {
132
+ throw new Error(formatControlPlaneAuthError(e, "API key revoke"));
133
+ }
134
+ const clearsLocal = config.apiKeyId === targetId;
135
+ if (clearsLocal) {
136
+ saveConfig({
137
+ ...config,
138
+ apiKey: undefined,
139
+ apiKeyId: undefined,
140
+ apiKeyDisplayName: undefined,
141
+ });
142
+ }
143
+ if (json) {
144
+ console.log(JSON.stringify({ ok: true, revokedKeyId: targetId, clearedLocal: clearsLocal }, null, 2));
145
+ return;
146
+ }
147
+ console.log(`Revoked API key ${targetId}.`);
148
+ if (clearsLocal) {
149
+ console.log("Local machine key cleared from ~/.trigguard/config.json.");
150
+ console.log("Run: tg login");
151
+ }
152
+ else {
153
+ console.log("Run: tg auth list");
154
+ }
155
+ }
156
+ export async function runAuthRotate(args) {
157
+ const json = hasFlag(args, "--json");
158
+ const { config, orgId } = await requireSession();
159
+ const rotateId = flagValue(args, "--key") ?? flagValue(args, "--id") ?? config.apiKeyId ?? undefined;
160
+ let rotated;
161
+ try {
162
+ rotated = await rotateApiKey(config, orgId, rotateId ?? undefined);
163
+ }
164
+ catch (e) {
165
+ throw new Error(formatControlPlaneAuthError(e, "API key rotation"));
166
+ }
167
+ const updatesLocal = !rotateId || rotateId === config.apiKeyId;
168
+ const envOverrides = envOverridesConfigApiKey(config);
169
+ if (updatesLocal) {
170
+ saveConfig({
171
+ ...config,
172
+ apiKey: rotated.rawKey,
173
+ apiKeyId: rotated.keyId,
174
+ apiKeyDisplayName: rotated.displayName,
175
+ });
176
+ }
177
+ if (json) {
178
+ console.log(JSON.stringify({
179
+ ok: true,
180
+ keyId: rotated.keyId,
181
+ displayName: rotated.displayName,
182
+ revokedKeyId: rotated.revokedKeyId ?? null,
183
+ localConfigUpdated: updatesLocal,
184
+ activeCredentialSource: envOverrides ? "env" : updatesLocal ? "config" : "remote",
185
+ envOverrideWarning: envOverrides && updatesLocal,
186
+ }, null, 2));
187
+ return;
188
+ }
189
+ console.log("API key rotated.");
190
+ console.log(`New key id: ${rotated.keyId}`);
191
+ if (updatesLocal && envOverrides) {
192
+ console.log("Local config updated, but TRIGGUARD_API_KEY overrides ~/.trigguard/config.json.");
193
+ console.log("Update or unset TRIGGUARD_API_KEY before running tg authorize.");
194
+ console.log("Run: unset TRIGGUARD_API_KEY");
195
+ }
196
+ else if (updatesLocal) {
197
+ console.log("Local config updated for this machine.");
198
+ console.log("Run: tg authorize --surface <surface> --actor <actor> --intent \"…\"");
199
+ }
200
+ else {
201
+ console.log("Run: tg auth list");
202
+ }
203
+ }
204
+ export async function runAuth(args) {
205
+ const sub = args[0];
206
+ const rest = args.slice(1);
207
+ if (sub === "list") {
208
+ await runAuthList(rest);
209
+ return;
210
+ }
211
+ if (sub === "revoke") {
212
+ await runAuthRevoke(rest);
213
+ return;
214
+ }
215
+ if (sub === "rotate") {
216
+ await runAuthRotate(rest);
217
+ return;
218
+ }
219
+ throw new Error([
220
+ "Unknown auth subcommand.",
221
+ "",
222
+ "Run:",
223
+ " tg auth list",
224
+ " tg auth revoke [--key <id>] [--yes]",
225
+ " tg auth rotate [--key <id>]",
226
+ ].join("\n"));
227
+ }
@@ -3,6 +3,7 @@ import { hasFlag } from "../tg/args.js";
3
3
  import { loadConfig, saveConfig } from "../cp/config.js";
4
4
  import { defaultMachineLabel, ensureCliApiKey, } from "../cp/provisionCliKey.js";
5
5
  import { startCliDeviceAuth, waitForCliDeviceAuth, } from "../cp/cliDeviceAuth.js";
6
+ import { formatNextAuthorizeCommand, printActivationEstablished, } from "../tg/activationCopy.js";
6
7
  async function openBrowser(url) {
7
8
  const platform = process.platform;
8
9
  let cmd;
@@ -34,7 +35,8 @@ export async function runLoginWeb(args) {
34
35
  if (!json) {
35
36
  console.log("Opening browser to sign in to TrigGuard...");
36
37
  console.log(`If the browser does not open, visit:\n ${started.verificationUrl}`);
37
- console.log(`Enter code: ${started.userCode}`);
38
+ console.log("Waiting for approval…");
39
+ console.log("Once approved, your CLI will continue automatically.");
38
40
  }
39
41
  try {
40
42
  await openBrowser(started.verificationUrl);
@@ -75,6 +77,5 @@ export async function runLoginWeb(args) {
75
77
  console.log(`Signed in as ${complete.user.email}`);
76
78
  console.log(`Workspace: ${complete.orgName ?? orgId}`);
77
79
  console.log("API key configured for this machine.");
78
- console.log("");
79
- console.log("Next: tg authorize --surface deploy.release --actor demo --intent \"test deploy\"");
80
+ printActivationEstablished(formatNextAuthorizeCommand());
80
81
  }
@@ -1,10 +1,13 @@
1
1
  import readline from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
3
  import { ControlPlaneError, fetchDashboardOrgs, fetchMe, loginWithPassword, resolveActiveOrg, } from "../cp/client.js";
4
- import { loadConfig, saveConfig, clearSession } from "../cp/config.js";
5
- import { resolveApiKeySource, storedCliApiKeyForSession } from "../cp/credentials.js";
4
+ import { loadConfig, saveConfig, clearSession, configFileCorrupted, configPath } from "../cp/config.js";
5
+ import { storedCliApiKeyForSession } from "../cp/credentials.js";
6
+ import { formatCorruptConfigError, formatNotLoggedInError, formatRevokedKeyGuidance, } from "../cp/errors.js";
7
+ import { recommendedNextCommand, resolveKeyVisibility } from "../cp/sessionContext.js";
6
8
  import { defaultMachineLabel, ensureCliApiKey } from "../cp/provisionCliKey.js";
7
9
  import { runLoginWeb } from "./login-web.js";
10
+ import { formatNextAuthorizeCommand, printActivationEstablished, } from "../tg/activationCopy.js";
8
11
  function flagValue(args, name) {
9
12
  const i = args.indexOf(name);
10
13
  if (i === -1)
@@ -188,8 +191,10 @@ export async function runLogin(args) {
188
191
  console.log(`Workspace: ${activeOrgId}`);
189
192
  else
190
193
  console.log("Workspace: (verify email to list organizations)");
191
- if (apiKey)
194
+ if (apiKey) {
192
195
  console.log("API key configured for this machine.");
196
+ printActivationEstablished(formatNextAuthorizeCommand());
197
+ }
193
198
  }
194
199
  export async function runLogout(args) {
195
200
  const json = hasFlag(args, "--json");
@@ -202,28 +207,41 @@ export async function runLogout(args) {
202
207
  }
203
208
  export async function runWhoami(args) {
204
209
  const json = hasFlag(args, "--json");
210
+ if (configFileCorrupted()) {
211
+ throw new Error(formatCorruptConfigError(configPath()));
212
+ }
205
213
  const config = loadConfig();
206
214
  const snap = await resolveSessionSnapshot();
207
- const apiKeySource = resolveApiKeySource(config);
208
- const apiKeyConfigured = apiKeySource !== "none";
215
+ const key = await resolveKeyVisibility(config, snap);
209
216
  if (!snap.authenticated || !snap.user) {
210
217
  if (json) {
211
218
  console.log(JSON.stringify({ authenticated: false, apiKeyConfigured: false }, null, 2));
212
219
  }
213
220
  else {
214
- console.log("Not signed in. Run: tg login");
221
+ console.log(formatNotLoggedInError());
215
222
  }
216
- process.exit(snap.authenticated ? 0 : 1);
223
+ process.exit(1);
217
224
  }
218
225
  if (json) {
219
226
  console.log(JSON.stringify({
220
227
  authenticated: true,
221
228
  user: snap.user,
222
- activeOrg: snap.activeOrg,
223
- controlPlaneUrl: snap.controlPlaneUrl,
229
+ organization: snap.activeOrg?.name ?? null,
230
+ workspace: snap.activeOrg?.orgId ?? null,
231
+ plan: snap.activeOrg?.plan ?? null,
224
232
  environment: snap.environment,
225
- apiKeyConfigured,
226
- apiKeySource,
233
+ authSource: "session",
234
+ apiKeyConfigured: key.apiKeyConfigured,
235
+ apiKeySource: key.apiKeySource,
236
+ apiKeyId: key.apiKeyId,
237
+ apiKeyDisplayName: key.apiKeyDisplayName,
238
+ machineName: key.machineName,
239
+ configPath: key.configPath,
240
+ keyStatus: key.keyStatus,
241
+ keyCreated: key.keyCreated,
242
+ keyLastUsed: key.keyLastUsed,
243
+ keyExpires: key.keyExpires,
244
+ keyScopes: key.keyScopes,
227
245
  }, null, 2));
228
246
  return;
229
247
  }
@@ -238,15 +256,50 @@ export async function runWhoami(args) {
238
256
  console.log("Email verification required before workspace listing.");
239
257
  }
240
258
  console.log(`Environment: ${snap.environment}`);
241
- console.log(`API key configured: ${apiKeyConfigured ? "yes" : "no"}`);
242
- console.log(`API key source: ${apiKeySource}`);
259
+ console.log(`Auth source: session`);
260
+ console.log(`API key configured: ${key.apiKeyConfigured ? "yes" : "no"}`);
261
+ console.log(`API key source: ${key.apiKeySource}`);
262
+ console.log(`API key id: ${key.apiKeyId}`);
263
+ console.log(`API key name: ${key.apiKeyDisplayName}`);
264
+ console.log(`Machine name: ${key.machineName}`);
265
+ console.log(`Config path: ${key.configPath}`);
266
+ console.log(`Key status: ${key.keyStatus}`);
267
+ console.log(`Key created: ${key.keyCreated}`);
268
+ console.log(`Key last used: ${key.keyLastUsed}`);
269
+ console.log(`Key expires: ${key.keyExpires}`);
270
+ console.log(`Key scopes: ${key.keyScopes}`);
271
+ if (key.keyRevoked) {
272
+ console.log(formatRevokedKeyGuidance());
273
+ }
243
274
  }
244
275
  export async function runStatus(args) {
245
276
  const json = hasFlag(args, "--json");
277
+ if (configFileCorrupted()) {
278
+ throw new Error(formatCorruptConfigError(configPath()));
279
+ }
246
280
  const config = loadConfig();
247
281
  const snap = await resolveSessionSnapshot();
248
- const apiKeySource = resolveApiKeySource(config);
249
- const apiKeyConfigured = apiKeySource !== "none";
282
+ const key = await resolveKeyVisibility(config, snap);
283
+ const next = recommendedNextCommand(snap, key);
284
+ let controlPlaneReachable = false;
285
+ if (snap.authenticated && config.sessionToken) {
286
+ try {
287
+ await fetch(`${config.controlPlaneUrl}/me`, {
288
+ headers: { Authorization: `Bearer ${config.sessionToken}`, Accept: "application/json" },
289
+ signal: AbortSignal.timeout(5_000),
290
+ }).then((r) => {
291
+ controlPlaneReachable = r.ok;
292
+ });
293
+ }
294
+ catch {
295
+ controlPlaneReachable = false;
296
+ }
297
+ }
298
+ const authorityStatus = snap.authenticated && key.apiKeyConfigured && !key.keyRevoked
299
+ ? "ready"
300
+ : key.keyRevoked
301
+ ? "key_revoked"
302
+ : "needs_login";
250
303
  const payload = {
251
304
  authentication: snap.authenticated ? "signed_in" : "signed_out",
252
305
  organization: snap.activeOrg?.name ?? null,
@@ -256,25 +309,35 @@ export async function runStatus(args) {
256
309
  apiEndpoint: snap.controlPlaneUrl,
257
310
  emailVerified: Boolean(snap.user?.emailVerifiedAt),
258
311
  user: snap.user?.email ?? null,
259
- apiKeyConfigured,
260
- apiKeySource,
261
- authorityStatus: snap.authenticated && apiKeyConfigured ? "ready" : "needs_login",
312
+ apiKeyConfigured: key.apiKeyConfigured,
313
+ apiKeySource: key.apiKeySource,
314
+ apiKeyId: key.apiKeyId,
315
+ apiKeyDisplayName: key.apiKeyDisplayName,
316
+ keyStatus: key.keyStatus,
317
+ controlPlaneReachable,
318
+ authorityStatus,
262
319
  verificationStatus: "available",
320
+ recommendedNextCommand: next,
263
321
  };
264
322
  if (json) {
265
323
  console.log(JSON.stringify(payload, null, 2));
266
324
  return;
267
325
  }
268
326
  console.log("TrigGuard CLI status");
269
- console.log(` Authentication: ${payload.authentication}`);
270
- console.log(` User: ${payload.user ?? "—"}`);
271
- console.log(` Organization: ${payload.organization ?? "—"}`);
272
- console.log(` Workspace: ${payload.workspace ?? "—"}`);
273
- console.log(` Plan: ${payload.plan ?? "—"}`);
274
- console.log(` Environment: ${payload.environment}`);
275
- console.log(` API endpoint: ${payload.apiEndpoint}`);
276
- console.log(` Email verified: ${payload.emailVerified ? "yes" : "no"}`);
277
- console.log(` API key: ${apiKeyConfigured ? `configured (${apiKeySource})` : "not configured"}`);
278
- console.log(` Authority: ${payload.authorityStatus}`);
279
- console.log(` Verification: ${payload.verificationStatus}`);
327
+ console.log(` Authentication: ${payload.authentication}`);
328
+ console.log(` User: ${payload.user ?? "—"}`);
329
+ console.log(` Organization: ${payload.organization ?? "—"}`);
330
+ console.log(` Workspace: ${payload.workspace ?? "—"}`);
331
+ console.log(` Plan: ${payload.plan ?? "—"}`);
332
+ console.log(` Environment: ${payload.environment}`);
333
+ console.log(` API endpoint: ${payload.apiEndpoint}`);
334
+ console.log(` Control plane: ${controlPlaneReachable ? "reachable" : "unreachable"}`);
335
+ console.log(` Email verified: ${payload.emailVerified ? "yes" : "no"}`);
336
+ console.log(` API key: ${key.apiKeyConfigured ? `configured (${key.apiKeySource})` : "not configured"}`);
337
+ console.log(` API key id: ${key.apiKeyId}`);
338
+ console.log(` API key name: ${key.apiKeyDisplayName}`);
339
+ console.log(` Key status: ${key.keyStatus}`);
340
+ console.log(` Authority readiness: ${authorityStatus}`);
341
+ console.log(` Verification: ${payload.verificationStatus}`);
342
+ console.log(` Next command: ${next}`);
280
343
  }
@@ -5,6 +5,7 @@ import { resolveSessionSnapshot } from "./session.js";
5
5
  import { runLoginWeb } from "./login-web.js";
6
6
  import { loadExecutionSurfaces } from "./tg-surfaces.js";
7
7
  import { shellQuoteArg } from "../tg/shellQuote.js";
8
+ import { printActivationEstablished } from "../tg/activationCopy.js";
8
9
  import os from "node:os";
9
10
  export async function runTgSetup(args) {
10
11
  const json = hasFlag(args, "--json");
@@ -16,6 +17,9 @@ export async function runTgSetup(args) {
16
17
  console.log("Step 1/3 — Sign in (browser)");
17
18
  await runLoginWeb(json ? ["--json"] : []);
18
19
  }
20
+ else if (!json) {
21
+ printActivationEstablished(`tg authorize --surface deploy.release --actor ${shellQuoteArg(snap.activeOrg?.name ?? os.userInfo().username ?? "demo")} --intent "setup test"`);
22
+ }
19
23
  const refreshed = loadConfig();
20
24
  const snap2 = await resolveSessionSnapshot();
21
25
  if (!snap2.authenticated || !resolveApiKey(refreshed)) {
@@ -0,0 +1,26 @@
1
+ import type { TrigGuardCliConfig } from "./types.js";
2
+ export interface ApiKeyPublicRecord {
3
+ readonly keyId: string;
4
+ readonly orgId: string;
5
+ readonly keyPrefix?: string;
6
+ readonly status?: string;
7
+ readonly displayName?: string;
8
+ readonly environment?: string;
9
+ readonly createdAt?: string;
10
+ readonly lastUsedAt?: string | null;
11
+ readonly expiresAt?: string | null;
12
+ readonly revokedAt?: string | null;
13
+ readonly rotatedFromKeyId?: string | null;
14
+ readonly revoked?: boolean;
15
+ readonly scopes?: readonly string[];
16
+ }
17
+ export declare function listApiKeys(config: TrigGuardCliConfig, orgId: string): Promise<ApiKeyPublicRecord[]>;
18
+ export declare function revokeApiKeyById(config: TrigGuardCliConfig, keyId: string): Promise<void>;
19
+ export declare function rotateApiKey(config: TrigGuardCliConfig, orgId: string, keyId?: string): Promise<{
20
+ rawKey: string;
21
+ keyId: string;
22
+ displayName: string;
23
+ revokedKeyId?: string;
24
+ }>;
25
+ /** Display field or explicit not-reported sentinel — never null/undefined in output. */
26
+ export declare function formatKeyField(value: string | null | undefined): string;
@@ -0,0 +1,38 @@
1
+ import { controlPlaneFetch } from "./client.js";
2
+ export async function listApiKeys(config, orgId) {
3
+ const data = await controlPlaneFetch(config, `/apikeys?orgId=${encodeURIComponent(orgId)}`, { method: "GET" });
4
+ return Array.isArray(data.keys) ? data.keys : [];
5
+ }
6
+ export async function revokeApiKeyById(config, keyId) {
7
+ await controlPlaneFetch(config, `/apikeys/${encodeURIComponent(keyId)}/revoke`, {
8
+ method: "POST",
9
+ headers: { "Content-Type": "application/json" },
10
+ body: JSON.stringify({}),
11
+ });
12
+ }
13
+ export async function rotateApiKey(config, orgId, keyId) {
14
+ const data = await controlPlaneFetch(config, "/apikeys/rotate", {
15
+ method: "POST",
16
+ headers: { "Content-Type": "application/json" },
17
+ body: JSON.stringify({ orgId, ...(keyId ? { keyId } : {}) }),
18
+ });
19
+ const rawKey = typeof data.rawKey === "string" ? data.rawKey : "";
20
+ const newKeyId = typeof data.keyId === "string" ? data.keyId : "";
21
+ const displayName = typeof data.displayName === "string" ? data.displayName : "TrigGuard CLI";
22
+ if (!rawKey.startsWith("tg_live_") || !newKeyId) {
23
+ throw new Error("rotate_response_invalid");
24
+ }
25
+ return {
26
+ rawKey,
27
+ keyId: newKeyId,
28
+ displayName,
29
+ revokedKeyId: typeof data.revokedKeyId === "string" ? data.revokedKeyId : undefined,
30
+ };
31
+ }
32
+ /** Display field or explicit not-reported sentinel — never null/undefined in output. */
33
+ export function formatKeyField(value) {
34
+ if (value === null || value === undefined || String(value).trim() === "") {
35
+ return "Not reported";
36
+ }
37
+ return String(value);
38
+ }
@@ -1,6 +1,7 @@
1
1
  import type { TrigGuardCliConfig, TrigGuardEnvironment } from "./types.js";
2
2
  export declare function configDir(): string;
3
3
  export declare function configPath(): string;
4
+ export declare function configFileCorrupted(): boolean;
4
5
  export declare function defaultControlPlaneUrl(): string;
5
6
  export declare function defaultEnvironment(): TrigGuardEnvironment;
6
7
  export declare function loadConfig(): TrigGuardCliConfig;
package/dist/cp/config.js CHANGED
@@ -12,6 +12,18 @@ export function configDir() {
12
12
  export function configPath() {
13
13
  return path.join(configDir(), "config.json");
14
14
  }
15
+ export function configFileCorrupted() {
16
+ const file = configPath();
17
+ if (!fs.existsSync(file))
18
+ return false;
19
+ try {
20
+ JSON.parse(fs.readFileSync(file, "utf8"));
21
+ return false;
22
+ }
23
+ catch {
24
+ return true;
25
+ }
26
+ }
15
27
  function parseEnvironment(raw) {
16
28
  switch ((raw ?? "production").trim().toLowerCase()) {
17
29
  case "staging":
@@ -43,12 +55,40 @@ function readConfigFile(base) {
43
55
  return null;
44
56
  }
45
57
  }
58
+ function enforceConfigPermissions(dir, file) {
59
+ try {
60
+ fs.chmodSync(dir, 0o700);
61
+ }
62
+ catch {
63
+ /* best effort */
64
+ }
65
+ if (fs.existsSync(file)) {
66
+ try {
67
+ fs.chmodSync(file, 0o600);
68
+ }
69
+ catch {
70
+ /* best effort */
71
+ }
72
+ }
73
+ }
74
+ function writeConfigAtomic(payload) {
75
+ const dir = configDir();
76
+ const file = configPath();
77
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
78
+ const tmp = `${file}.${process.pid}.tmp`;
79
+ fs.writeFileSync(tmp, payload, { mode: 0o600 });
80
+ fs.renameSync(tmp, file);
81
+ enforceConfigPermissions(dir, file);
82
+ }
46
83
  export function loadConfig() {
47
84
  const base = {
48
85
  version: CONFIG_VERSION,
49
86
  controlPlaneUrl: defaultControlPlaneUrl(),
50
87
  environment: defaultEnvironment(),
51
88
  };
89
+ if (configFileCorrupted()) {
90
+ return base;
91
+ }
52
92
  const parsed = readConfigFile(base);
53
93
  if (parsed?.sessionRevoked) {
54
94
  return {
@@ -85,8 +125,6 @@ export function loadConfig() {
85
125
  };
86
126
  }
87
127
  export function saveConfig(config) {
88
- const dir = configDir();
89
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
90
128
  const payload = {
91
129
  version: CONFIG_VERSION,
92
130
  controlPlaneUrl: config.controlPlaneUrl.replace(/\/$/, ""),
@@ -99,7 +137,7 @@ export function saveConfig(config) {
99
137
  apiKeyDisplayName: config.apiKeyDisplayName,
100
138
  ...(config.sessionToken ? {} : config.sessionRevoked ? { sessionRevoked: true } : {}),
101
139
  };
102
- fs.writeFileSync(configPath(), `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
140
+ writeConfigAtomic(`${JSON.stringify(payload, null, 2)}\n`);
103
141
  }
104
142
  export function clearSession(config) {
105
143
  const next = {
@@ -3,6 +3,7 @@ export type ApiKeySource = "env" | "config" | "none";
3
3
  export declare function resolveApiKeyFromEnv(): string | undefined;
4
4
  export declare function resolveApiKey(config: TrigGuardCliConfig): string | undefined;
5
5
  export declare function resolveApiKeySource(config: TrigGuardCliConfig): ApiKeySource;
6
+ export declare function envOverridesConfigApiKey(config: TrigGuardCliConfig): boolean;
6
7
  export declare function storedCliApiKeyForSession(config: TrigGuardCliConfig, user: {
7
8
  email: string;
8
9
  }, activeOrgId: string | undefined): string | null;
@@ -13,6 +13,9 @@ export function resolveApiKeySource(config) {
13
13
  return "config";
14
14
  return "none";
15
15
  }
16
+ export function envOverridesConfigApiKey(config) {
17
+ return resolveApiKeySource(config) === "env";
18
+ }
16
19
  export function storedCliApiKeyForSession(config, user, activeOrgId) {
17
20
  if (!activeOrgId ||
18
21
  config.user?.email !== user.email ||
@@ -25,7 +28,12 @@ export function storedCliApiKeyForSession(config, user, activeOrgId) {
25
28
  export function missingConfiguredApiKeyMessage() {
26
29
  return [
27
30
  "No API key configured.",
28
- "Run: tg login",
31
+ "",
32
+ "TrigGuard CLI requires a machine-scoped API key to authorize execution.",
33
+ "",
34
+ "Run:",
35
+ " tg login",
36
+ "",
29
37
  "Or export TRIGGUARD_API_KEY for CI/scripts.",
30
38
  ].join("\n");
31
39
  }
@@ -0,0 +1,5 @@
1
+ export declare function formatNotLoggedInError(): string;
2
+ export declare function formatNoApiKeyError(): string;
3
+ export declare function formatCorruptConfigError(configPath: string): string;
4
+ export declare function formatControlPlaneAuthError(e: unknown, context: string): string;
5
+ export declare function formatRevokedKeyGuidance(): string;
@@ -0,0 +1,139 @@
1
+ import { ControlPlaneError } from "./client.js";
2
+ export function formatNotLoggedInError() {
3
+ return [
4
+ "Not signed in.",
5
+ "",
6
+ "TrigGuard CLI requires an authenticated session to manage API keys.",
7
+ "",
8
+ "Run:",
9
+ " tg login",
10
+ ].join("\n");
11
+ }
12
+ export function formatNoApiKeyError() {
13
+ return [
14
+ "No API key configured.",
15
+ "",
16
+ "TrigGuard CLI requires a machine-scoped API key to authorize execution.",
17
+ "",
18
+ "Run:",
19
+ " tg login",
20
+ "",
21
+ "Or export TRIGGUARD_API_KEY for CI/scripts.",
22
+ ].join("\n");
23
+ }
24
+ export function formatCorruptConfigError(configPath) {
25
+ return [
26
+ "Local TrigGuard config is corrupted and could not be parsed.",
27
+ "",
28
+ `Config path: ${configPath}`,
29
+ "",
30
+ "Run:",
31
+ " tg logout",
32
+ " tg login",
33
+ ].join("\n");
34
+ }
35
+ export function formatControlPlaneAuthError(e, context) {
36
+ if (e instanceof ControlPlaneError) {
37
+ switch (e.code) {
38
+ case "forbidden":
39
+ return [
40
+ "Permission denied.",
41
+ "",
42
+ `TrigGuard control plane rejected this ${context} request.`,
43
+ "",
44
+ "Run:",
45
+ " tg whoami",
46
+ " tg status",
47
+ ].join("\n");
48
+ case "email_verification_required":
49
+ return [
50
+ "Email verification required.",
51
+ "",
52
+ "Verify your email in the TrigGuard console, then retry.",
53
+ "",
54
+ "Run:",
55
+ " tg login",
56
+ ].join("\n");
57
+ case "not_found":
58
+ case "key_not_found":
59
+ return [
60
+ "API key not found.",
61
+ "",
62
+ "The key may already be revoked or belongs to another workspace.",
63
+ "",
64
+ "Run:",
65
+ " tg auth list",
66
+ ].join("\n");
67
+ case "no_active_key":
68
+ return [
69
+ "No active API key to rotate.",
70
+ "",
71
+ "Run:",
72
+ " tg login",
73
+ ].join("\n");
74
+ case "rotate_failed":
75
+ return [
76
+ "API key rotation failed.",
77
+ "",
78
+ "The control plane could not rotate this key.",
79
+ "",
80
+ "Run:",
81
+ " tg auth list",
82
+ " tg login",
83
+ ].join("\n");
84
+ default:
85
+ if (e.status === 401) {
86
+ return [
87
+ "Session expired or invalid.",
88
+ "",
89
+ "Run:",
90
+ " tg login",
91
+ ].join("\n");
92
+ }
93
+ if (e.status >= 500) {
94
+ return [
95
+ "Control plane unavailable.",
96
+ "",
97
+ "TrigGuard could not reach the control plane API.",
98
+ "",
99
+ "Run:",
100
+ " tg status",
101
+ ].join("\n");
102
+ }
103
+ }
104
+ }
105
+ if (e instanceof Error && e.message === "device_code_expired") {
106
+ return [
107
+ "Device authorization expired.",
108
+ "",
109
+ "Run:",
110
+ " tg login",
111
+ ].join("\n");
112
+ }
113
+ if (e instanceof Error && e.message === "device_login_timeout") {
114
+ return [
115
+ "Timed out waiting for browser approval.",
116
+ "",
117
+ "Run:",
118
+ " tg login",
119
+ ].join("\n");
120
+ }
121
+ const msg = e instanceof Error ? e.message : String(e);
122
+ return [
123
+ `${context} failed.`,
124
+ "",
125
+ msg,
126
+ "",
127
+ "Run:",
128
+ " tg status",
129
+ ].join("\n");
130
+ }
131
+ export function formatRevokedKeyGuidance() {
132
+ return [
133
+ "Configured API key appears revoked or inactive.",
134
+ "",
135
+ "Run:",
136
+ " tg auth rotate",
137
+ " tg login",
138
+ ].join("\n");
139
+ }
@@ -0,0 +1,21 @@
1
+ import { type ApiKeyPublicRecord } from "./apiKeys.js";
2
+ import { resolveApiKeySource } from "./credentials.js";
3
+ import type { SessionSnapshot, TrigGuardCliConfig } from "./types.js";
4
+ export interface KeyVisibility {
5
+ readonly apiKeyConfigured: boolean;
6
+ readonly apiKeySource: ReturnType<typeof resolveApiKeySource>;
7
+ readonly apiKeyId: string;
8
+ readonly apiKeyDisplayName: string;
9
+ readonly machineName: string;
10
+ readonly configPath: string;
11
+ readonly keyStatus: string;
12
+ readonly keyCreated: string;
13
+ readonly keyLastUsed: string;
14
+ readonly keyExpires: string;
15
+ readonly keyScopes: string;
16
+ readonly keyRevoked: boolean;
17
+ }
18
+ export declare function machineNameFromConfig(config: TrigGuardCliConfig): string;
19
+ export declare function resolveActiveApiKeyRecord(keys: readonly ApiKeyPublicRecord[], config: TrigGuardCliConfig): ApiKeyPublicRecord | undefined;
20
+ export declare function resolveKeyVisibility(config: TrigGuardCliConfig, snap: SessionSnapshot): Promise<KeyVisibility>;
21
+ export declare function recommendedNextCommand(snap: SessionSnapshot, key: KeyVisibility): string;
@@ -0,0 +1,86 @@
1
+ import os from "node:os";
2
+ import { listApiKeys, formatKeyField } from "./apiKeys.js";
3
+ import { configPath } from "./config.js";
4
+ import { resolveApiKeyFromEnv, resolveApiKeySource } from "./credentials.js";
5
+ export function machineNameFromConfig(config) {
6
+ const label = config.apiKeyDisplayName?.match(/TrigGuard CLI \((.+)\)/)?.[1];
7
+ return label?.trim() || os.hostname().trim() || "cli";
8
+ }
9
+ function matchByEnvPrefix(keys, envKey) {
10
+ return keys.find((k) => {
11
+ const prefix = k.keyPrefix?.trim();
12
+ return Boolean(prefix && envKey.startsWith(prefix));
13
+ });
14
+ }
15
+ export function resolveActiveApiKeyRecord(keys, config) {
16
+ const apiKeySource = resolveApiKeySource(config);
17
+ const envKey = resolveApiKeyFromEnv();
18
+ if (apiKeySource === "env" && envKey) {
19
+ return matchByEnvPrefix(keys, envKey);
20
+ }
21
+ if (config.apiKeyId) {
22
+ return keys.find((k) => k.keyId === config.apiKeyId);
23
+ }
24
+ return undefined;
25
+ }
26
+ export async function resolveKeyVisibility(config, snap) {
27
+ const apiKeySource = resolveApiKeySource(config);
28
+ const apiKeyConfigured = apiKeySource !== "none";
29
+ const base = {
30
+ apiKeyConfigured,
31
+ apiKeySource,
32
+ apiKeyId: formatKeyField(config.apiKeyId),
33
+ apiKeyDisplayName: formatKeyField(config.apiKeyDisplayName),
34
+ machineName: machineNameFromConfig(config),
35
+ configPath: configPath(),
36
+ keyStatus: "Not reported",
37
+ keyCreated: "Not reported",
38
+ keyLastUsed: "Not reported",
39
+ keyExpires: "Not reported",
40
+ keyScopes: "Not reported",
41
+ keyRevoked: false,
42
+ };
43
+ if (!snap.authenticated || !snap.activeOrg?.orgId || !config.sessionToken) {
44
+ return base;
45
+ }
46
+ try {
47
+ const keys = await listApiKeys(config, snap.activeOrg.orgId);
48
+ const stored = resolveActiveApiKeyRecord(keys, config);
49
+ if (!stored) {
50
+ if (apiKeySource === "env") {
51
+ return {
52
+ ...base,
53
+ apiKeyId: "Not reported",
54
+ apiKeyDisplayName: "Not reported",
55
+ };
56
+ }
57
+ return base;
58
+ }
59
+ return {
60
+ ...base,
61
+ apiKeyId: stored.keyId,
62
+ apiKeyDisplayName: formatKeyField(stored.displayName ?? config.apiKeyDisplayName),
63
+ keyStatus: formatKeyField(stored.status ?? (stored.revoked ? "revoked" : "active")),
64
+ keyCreated: formatKeyField(stored.createdAt),
65
+ keyLastUsed: formatKeyField(stored.lastUsedAt ?? undefined),
66
+ keyExpires: formatKeyField(stored.expiresAt ?? undefined),
67
+ keyScopes: stored.scopes?.length ? stored.scopes.join(", ") : "Not reported",
68
+ keyRevoked: Boolean(stored.revoked || stored.status === "revoked"),
69
+ };
70
+ }
71
+ catch {
72
+ return base;
73
+ }
74
+ }
75
+ export function recommendedNextCommand(snap, key) {
76
+ if (!snap.authenticated)
77
+ return "tg login";
78
+ if (!key.apiKeyConfigured)
79
+ return "tg login";
80
+ if (key.keyRevoked) {
81
+ return key.apiKeySource === "env"
82
+ ? "unset TRIGGUARD_API_KEY && tg login"
83
+ : "tg auth rotate";
84
+ }
85
+ return 'tg authorize --surface <surface> --actor <actor> --intent "…"';
86
+ }
@@ -0,0 +1,9 @@
1
+ export declare const ACTIVATION_HEADLINE = "Execution Authority Established";
2
+ export declare const ACTIVATION_STATUS = "Authority Status: Verified";
3
+ export type NextAuthorizeOptions = {
4
+ surface?: string;
5
+ actor?: string;
6
+ intent?: string;
7
+ };
8
+ export declare function formatNextAuthorizeCommand(opts?: NextAuthorizeOptions): string;
9
+ export declare function printActivationEstablished(nextCommand: string): void;
@@ -0,0 +1,43 @@
1
+ import { spawnSync } from "node:child_process";
2
+ export const ACTIVATION_HEADLINE = "Execution Authority Established";
3
+ export const ACTIVATION_STATUS = "Authority Status: Verified";
4
+ export function formatNextAuthorizeCommand(opts = {}) {
5
+ const surface = opts.surface ?? "deploy.release";
6
+ const actor = opts.actor ?? "demo";
7
+ const intent = opts.intent ?? "test deployment";
8
+ return `tg authorize --surface ${surface} --actor ${actor} --intent "${intent}"`;
9
+ }
10
+ function tryCopyToClipboard(text) {
11
+ if (!process.stdout.isTTY)
12
+ return false;
13
+ const platform = process.platform;
14
+ if (platform === "darwin") {
15
+ const r = spawnSync("pbcopy", [], { input: text, stdio: ["pipe", "ignore", "ignore"] });
16
+ return r.status === 0;
17
+ }
18
+ if (platform === "win32") {
19
+ const r = spawnSync("clip", [], { input: text, stdio: ["pipe", "ignore", "ignore"], shell: true });
20
+ return r.status === 0;
21
+ }
22
+ for (const [cmd, args] of [
23
+ ["xclip", ["-selection", "clipboard"]],
24
+ ["xsel", ["--clipboard", "--input"]],
25
+ ]) {
26
+ const r = spawnSync(cmd, [...args], { input: text, stdio: ["pipe", "ignore", "ignore"] });
27
+ if (r.status === 0)
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+ export function printActivationEstablished(nextCommand) {
33
+ console.log("");
34
+ console.log(ACTIVATION_HEADLINE);
35
+ console.log(ACTIVATION_STATUS);
36
+ console.log("");
37
+ console.log("Next step:");
38
+ console.log(` ${nextCommand}`);
39
+ if (tryCopyToClipboard(nextCommand)) {
40
+ console.log("");
41
+ console.log("(Command copied to clipboard)");
42
+ }
43
+ }
package/dist/tg/help.js CHANGED
@@ -13,8 +13,11 @@ COMMANDS
13
13
  surfaces List registered execution surfaces
14
14
  login Sign in (browser device flow by default)
15
15
  logout Clear local session
16
- whoami Current user and workspace
17
- status Auth, org, environment summary
16
+ whoami Current user, workspace, and API key metadata
17
+ status Auth, connectivity, and next-step guidance
18
+ auth list List workspace API keys (no raw secrets)
19
+ auth revoke Revoke a machine API key
20
+ auth rotate Rotate credentials safely
18
21
 
19
22
  QUICK START
20
23
  npm install -g @trigguard/cli
@@ -30,6 +33,7 @@ ENVIRONMENT
30
33
  TRIGGUARD_CONTROL_PLANE_URL Control plane URL (session commands)
31
34
 
32
35
  DOCS
36
+ docs/developer/CLI_SECURITY_MODEL_V1.md
33
37
  docs/developer/TRIGGUARD_CLI_ZERO_CONFIG_AUTH.md
34
38
  docs/developer/TRIGGUARD_QUICKSTART.md
35
39
  `);
package/dist/tg.js CHANGED
@@ -4,6 +4,7 @@ import { runTgInit } from "./commands/tg-init.js";
4
4
  import { runTgSetup } from "./commands/tg-setup.js";
5
5
  import { runTgSurfaces } from "./commands/tg-surfaces.js";
6
6
  import { runTgVerify } from "./commands/tg-verify.js";
7
+ import { runAuth } from "./commands/auth.js";
7
8
  import { runLogin, runLogout, runStatus, runWhoami } from "./commands/session.js";
8
9
  import { printAuthorizeHelp, printInitHelp, printLoginHelp, printRootHelp, printSetupHelp, printSurfacesHelp, printVerifyHelp, } from "./tg/help.js";
9
10
  async function main() {
@@ -72,6 +73,10 @@ async function main() {
72
73
  await runStatus(rest);
73
74
  return;
74
75
  }
76
+ if (cmd === "auth") {
77
+ await runAuth(rest);
78
+ return;
79
+ }
75
80
  }
76
81
  catch (e) {
77
82
  if (e instanceof Error && e.message === "login_cancelled") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trigguard/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "TrigGuard developer CLI — tg login, authorize, verify",
5
5
  "type": "module",
6
6
  "bin": {