@trigguard/cli 0.1.2 → 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
+ }
@@ -35,7 +35,8 @@ export async function runLoginWeb(args) {
35
35
  if (!json) {
36
36
  console.log("Opening browser to sign in to TrigGuard...");
37
37
  console.log(`If the browser does not open, visit:\n ${started.verificationUrl}`);
38
- console.log(`Enter code: ${started.userCode}`);
38
+ console.log("Waiting for approval…");
39
+ console.log("Once approved, your CLI will continue automatically.");
39
40
  }
40
41
  try {
41
42
  await openBrowser(started.verificationUrl);
@@ -1,8 +1,10 @@
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";
8
10
  import { formatNextAuthorizeCommand, printActivationEstablished, } from "../tg/activationCopy.js";
@@ -205,28 +207,41 @@ export async function runLogout(args) {
205
207
  }
206
208
  export async function runWhoami(args) {
207
209
  const json = hasFlag(args, "--json");
210
+ if (configFileCorrupted()) {
211
+ throw new Error(formatCorruptConfigError(configPath()));
212
+ }
208
213
  const config = loadConfig();
209
214
  const snap = await resolveSessionSnapshot();
210
- const apiKeySource = resolveApiKeySource(config);
211
- const apiKeyConfigured = apiKeySource !== "none";
215
+ const key = await resolveKeyVisibility(config, snap);
212
216
  if (!snap.authenticated || !snap.user) {
213
217
  if (json) {
214
218
  console.log(JSON.stringify({ authenticated: false, apiKeyConfigured: false }, null, 2));
215
219
  }
216
220
  else {
217
- console.log("Not signed in. Run: tg login");
221
+ console.log(formatNotLoggedInError());
218
222
  }
219
- process.exit(snap.authenticated ? 0 : 1);
223
+ process.exit(1);
220
224
  }
221
225
  if (json) {
222
226
  console.log(JSON.stringify({
223
227
  authenticated: true,
224
228
  user: snap.user,
225
- activeOrg: snap.activeOrg,
226
- controlPlaneUrl: snap.controlPlaneUrl,
229
+ organization: snap.activeOrg?.name ?? null,
230
+ workspace: snap.activeOrg?.orgId ?? null,
231
+ plan: snap.activeOrg?.plan ?? null,
227
232
  environment: snap.environment,
228
- apiKeyConfigured,
229
- 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,
230
245
  }, null, 2));
231
246
  return;
232
247
  }
@@ -241,15 +256,50 @@ export async function runWhoami(args) {
241
256
  console.log("Email verification required before workspace listing.");
242
257
  }
243
258
  console.log(`Environment: ${snap.environment}`);
244
- console.log(`API key configured: ${apiKeyConfigured ? "yes" : "no"}`);
245
- 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
+ }
246
274
  }
247
275
  export async function runStatus(args) {
248
276
  const json = hasFlag(args, "--json");
277
+ if (configFileCorrupted()) {
278
+ throw new Error(formatCorruptConfigError(configPath()));
279
+ }
249
280
  const config = loadConfig();
250
281
  const snap = await resolveSessionSnapshot();
251
- const apiKeySource = resolveApiKeySource(config);
252
- 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";
253
303
  const payload = {
254
304
  authentication: snap.authenticated ? "signed_in" : "signed_out",
255
305
  organization: snap.activeOrg?.name ?? null,
@@ -259,25 +309,35 @@ export async function runStatus(args) {
259
309
  apiEndpoint: snap.controlPlaneUrl,
260
310
  emailVerified: Boolean(snap.user?.emailVerifiedAt),
261
311
  user: snap.user?.email ?? null,
262
- apiKeyConfigured,
263
- apiKeySource,
264
- 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,
265
319
  verificationStatus: "available",
320
+ recommendedNextCommand: next,
266
321
  };
267
322
  if (json) {
268
323
  console.log(JSON.stringify(payload, null, 2));
269
324
  return;
270
325
  }
271
326
  console.log("TrigGuard CLI status");
272
- console.log(` Authentication: ${payload.authentication}`);
273
- console.log(` User: ${payload.user ?? "—"}`);
274
- console.log(` Organization: ${payload.organization ?? "—"}`);
275
- console.log(` Workspace: ${payload.workspace ?? "—"}`);
276
- console.log(` Plan: ${payload.plan ?? "—"}`);
277
- console.log(` Environment: ${payload.environment}`);
278
- console.log(` API endpoint: ${payload.apiEndpoint}`);
279
- console.log(` Email verified: ${payload.emailVerified ? "yes" : "no"}`);
280
- console.log(` API key: ${apiKeyConfigured ? `configured (${apiKeySource})` : "not configured"}`);
281
- console.log(` Authority: ${payload.authorityStatus}`);
282
- 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}`);
283
343
  }
@@ -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
+ }
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.2",
3
+ "version": "0.1.3",
4
4
  "description": "TrigGuard developer CLI — tg login, authorize, verify",
5
5
  "type": "module",
6
6
  "bin": {