@dac-tech-team-tw/itsaplan-cli 0.0.0-stage → 0.2.0

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,32 @@
1
+ import { type Context, type GlobalOptions } from "./contracts.js";
2
+ export type Storage = "keyring" | "file";
3
+ export interface UserConfig {
4
+ api_url?: string;
5
+ credentials?: Record<string, {
6
+ storage: Storage;
7
+ }>;
8
+ project_defaults?: Record<string, string>;
9
+ }
10
+ export declare function getConfigPaths(): {
11
+ directory: string;
12
+ config: string;
13
+ credentials: string;
14
+ };
15
+ export declare function normalizeApiUrl(input: string): string;
16
+ export declare function readUserConfig(): Promise<UserConfig>;
17
+ export declare function resolveApiUrl(options: GlobalOptions): Promise<string | undefined>;
18
+ export declare function resolveProject(options: GlobalOptions): Promise<string | undefined>;
19
+ export interface ProjectSelection {
20
+ projectRef?: string;
21
+ source: "flag" | "env" | "directory" | "user" | "missing";
22
+ }
23
+ export declare function resolveProjectSelection(options: GlobalOptions): Promise<ProjectSelection>;
24
+ export declare function saveUserProject(input: string, projectRef: string | undefined): Promise<{
25
+ changed: boolean;
26
+ }>;
27
+ export declare function saveAuth(input: string, token: string, storage?: Storage): Promise<void>;
28
+ export declare function removeAuth(input: string): Promise<{
29
+ removed: boolean;
30
+ storage?: string;
31
+ }>;
32
+ export declare function createContext(options: GlobalOptions): Promise<Context>;
package/dist/config.js ADDED
@@ -0,0 +1,608 @@
1
+ import { constants } from "node:fs";
2
+ import { access, chmod, lstat, mkdir, open, readFile, rename, rm, } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { randomUUID } from "node:crypto";
8
+ import { parse, stringify } from "smol-toml";
9
+ import { CliError } from "./contracts.js";
10
+ import { HttpClient, registerSecret } from "./http.js";
11
+ const exec = promisify(execFile);
12
+ const object = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
13
+ const missing = (error) => object(error) && error.code === "ENOENT";
14
+ export function getConfigPaths() {
15
+ let base;
16
+ if (process.platform === "darwin")
17
+ base = join(homedir(), "Library", "Application Support");
18
+ else if (process.platform === "win32") {
19
+ if (!process.env.APPDATA)
20
+ throw new CliError("CONFIG_PATH_UNAVAILABLE", "APPDATA is required to locate user configuration.");
21
+ base = process.env.APPDATA;
22
+ }
23
+ else
24
+ base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
25
+ if (!isAbsolute(base))
26
+ throw new CliError("INVALID_CONFIG_PATH", "The user configuration directory must be absolute.");
27
+ const directory = join(base, "itsaplan");
28
+ return {
29
+ directory,
30
+ config: join(directory, "config.toml"),
31
+ credentials: join(directory, "credentials.json"),
32
+ };
33
+ }
34
+ export function normalizeApiUrl(input) {
35
+ let url;
36
+ try {
37
+ url = new URL(input);
38
+ }
39
+ catch {
40
+ throw new CliError("INVALID_API_URL", "API URL must be an absolute HTTPS URL.");
41
+ }
42
+ const local = url.hostname === "localhost" ||
43
+ url.hostname === "127.0.0.1" ||
44
+ url.hostname === "[::1]";
45
+ if ((url.protocol !== "https:" && !(url.protocol === "http:" && local)) ||
46
+ url.username ||
47
+ url.password ||
48
+ url.search ||
49
+ url.hash ||
50
+ input.includes("?") ||
51
+ input.includes("#") ||
52
+ /[\\\s]/u.test(input)) {
53
+ throw new CliError("INVALID_API_URL", "Use HTTPS (HTTP only on localhost), without userinfo, query, fragment, or backslashes.");
54
+ }
55
+ url.pathname = url.pathname.replace(/\/+$/u, "");
56
+ return url.toString().replace(/\/$/u, "");
57
+ }
58
+ async function readToml(path) {
59
+ let text;
60
+ try {
61
+ text = await readFile(path, "utf8");
62
+ }
63
+ catch (error) {
64
+ if (missing(error))
65
+ return undefined;
66
+ throw new CliError("CONFIG_READ_FAILED", "Cannot read configuration file.");
67
+ }
68
+ try {
69
+ const parsed = parse(text);
70
+ if (object(parsed))
71
+ return parsed;
72
+ }
73
+ catch {
74
+ /* invalid TOML below */
75
+ }
76
+ throw new CliError("INVALID_CONFIG", "Configuration must be a valid TOML table.");
77
+ }
78
+ export async function readUserConfig() {
79
+ const raw = await readToml(getConfigPaths().config);
80
+ if (!raw)
81
+ return {};
82
+ const config = {};
83
+ if (raw.api_url !== undefined) {
84
+ if (typeof raw.api_url !== "string")
85
+ throw new CliError("INVALID_CONFIG", "User api_url must be a string.");
86
+ config.api_url = normalizeApiUrl(raw.api_url);
87
+ }
88
+ if (raw.credentials !== undefined) {
89
+ if (!object(raw.credentials))
90
+ throw new CliError("INVALID_CONFIG", "Credential metadata must be a table.");
91
+ config.credentials = {};
92
+ for (const [url, entry] of Object.entries(raw.credentials)) {
93
+ if (!object(entry) ||
94
+ (entry.storage !== "keyring" && entry.storage !== "file"))
95
+ throw new CliError("INVALID_CONFIG", "Unknown credential storage; use keyring or file.");
96
+ const normalized = normalizeApiUrl(url);
97
+ if (config.credentials[normalized])
98
+ throw new CliError("INVALID_CONFIG", "Duplicate normalized API credential endpoints.");
99
+ config.credentials[normalized] = { storage: entry.storage };
100
+ }
101
+ }
102
+ if (raw.project_defaults !== undefined) {
103
+ if (!object(raw.project_defaults))
104
+ throw new CliError("INVALID_CONFIG", "Project defaults must be a table.");
105
+ config.project_defaults = {};
106
+ for (const [url, ref] of Object.entries(raw.project_defaults)) {
107
+ if (typeof ref !== "string" ||
108
+ !/^[A-Za-z0-9_-]+\.[A-Za-z][A-Za-z0-9_-]*$/u.test(ref))
109
+ throw new CliError("INVALID_CONFIG", "Saved project defaults must be full team.KEY refs.");
110
+ const normalized = normalizeApiUrl(url);
111
+ if (Object.hasOwn(config.project_defaults, normalized))
112
+ throw new CliError("INVALID_CONFIG", "Duplicate normalized API project endpoints.");
113
+ config.project_defaults[normalized] = ref;
114
+ }
115
+ }
116
+ return config;
117
+ }
118
+ export async function resolveApiUrl(options) {
119
+ const value = options.apiUrl ??
120
+ process.env.ITSAPLAN_URL ??
121
+ (await readUserConfig()).api_url;
122
+ return value === undefined ? undefined : normalizeApiUrl(value);
123
+ }
124
+ async function exists(path) {
125
+ try {
126
+ await access(path);
127
+ return true;
128
+ }
129
+ catch (error) {
130
+ if (missing(error))
131
+ return false;
132
+ throw new CliError("CONFIG_READ_FAILED", "Cannot inspect project configuration boundary.");
133
+ }
134
+ }
135
+ export async function resolveProject(options) {
136
+ return (await resolveProjectSelection(options)).projectRef;
137
+ }
138
+ export async function resolveProjectSelection(options) {
139
+ const cwd = process.cwd();
140
+ const home = resolve(homedir());
141
+ let boundary = cwd;
142
+ const withinHome = cwd === home ||
143
+ (!relative(home, cwd).startsWith("..") && !isAbsolute(relative(home, cwd)));
144
+ let current = cwd;
145
+ while (true) {
146
+ if (await exists(join(current, ".git"))) {
147
+ boundary = current;
148
+ break;
149
+ }
150
+ if (withinHome && current === home) {
151
+ boundary = home;
152
+ break;
153
+ }
154
+ const parent = dirname(current);
155
+ if (parent === current) {
156
+ boundary = withinHome ? home : cwd;
157
+ break;
158
+ }
159
+ current = parent;
160
+ }
161
+ let project;
162
+ current = cwd;
163
+ while (true) {
164
+ const repo = await readToml(join(current, ".itsaplan.toml"));
165
+ if (repo) {
166
+ if ("api_url" in repo)
167
+ throw new CliError("UNTRUSTED_ENDPOINT", "Repository api_url is forbidden; move the endpoint to user configuration or pass --api-url.");
168
+ if (repo.project_ref !== undefined &&
169
+ (typeof repo.project_ref !== "string" || !repo.project_ref.trim()))
170
+ throw new CliError("INVALID_CONFIG", "Repository project_ref must be a nonempty string.");
171
+ project =
172
+ typeof repo.project_ref === "string" ? repo.project_ref : undefined;
173
+ break;
174
+ }
175
+ if (current === boundary)
176
+ break;
177
+ current = dirname(current);
178
+ }
179
+ let chosen = options.project ?? process.env.ITSAPLAN_PROJECT ?? project;
180
+ let source = options.project !== undefined
181
+ ? "flag"
182
+ : process.env.ITSAPLAN_PROJECT !== undefined
183
+ ? "env"
184
+ : project !== undefined
185
+ ? "directory"
186
+ : "missing";
187
+ if (chosen === undefined) {
188
+ const apiUrl = await resolveApiUrl(options);
189
+ if (apiUrl !== undefined)
190
+ chosen = (await readUserConfig()).project_defaults?.[apiUrl];
191
+ if (chosen !== undefined)
192
+ source = "user";
193
+ }
194
+ if (chosen !== undefined &&
195
+ !/^(?:[A-Za-z0-9_-]+\.)?[A-Za-z][A-Za-z0-9_-]*$/u.test(chosen))
196
+ throw new CliError("INVALID_PROJECT", "Use a project key or team.KEY ref.");
197
+ return { projectRef: chosen, source };
198
+ }
199
+ async function windowsSid() {
200
+ try {
201
+ const { stdout } = await exec("powershell.exe", [
202
+ "-NoProfile",
203
+ "-NonInteractive",
204
+ "-Command",
205
+ "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
206
+ ]);
207
+ const sid = stdout.trim();
208
+ if (!/^S-1-[0-9-]+$/u.test(sid))
209
+ throw new Error("Invalid SID");
210
+ return sid;
211
+ }
212
+ catch {
213
+ throw new CliError("SECURE_STORAGE_UNAVAILABLE", "Cannot determine the current Windows user SID; no credentials were saved.");
214
+ }
215
+ }
216
+ async function windowsPermissions(path, directory, set) {
217
+ try {
218
+ const sid = await windowsSid();
219
+ const grant = directory ? "(OI)(CI)F" : "F";
220
+ if (set)
221
+ await exec("icacls.exe", [
222
+ path,
223
+ "/inheritance:r",
224
+ "/grant:r",
225
+ `*${sid}:${grant}`,
226
+ `*S-1-5-18:${grant}`,
227
+ `*S-1-5-32-544:${grant}`,
228
+ ]);
229
+ const script = '$a=Get-Acl -LiteralPath $env:IAP_ACL_PATH; $sid=$env:IAP_ACL_SID; $ok=$a.AreAccessRulesProtected; $mine=$false; foreach($r in $a.Access){ $s=$r.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value; if($s -notin @($sid,"S-1-5-18","S-1-5-32-544") -or $r.AccessControlType -ne "Allow"){ $ok=$false }; if($s -eq $sid -and ($r.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl){ $mine=$true } }; if($ok -and $mine){"secure"}else{exit 1}';
230
+ const { stdout } = await exec("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { env: { ...process.env, IAP_ACL_PATH: path, IAP_ACL_SID: sid } });
231
+ if (stdout.trim() !== "secure")
232
+ throw new Error("ACL verification failed");
233
+ }
234
+ catch {
235
+ throw new CliError("INSECURE_CREDENTIAL_STORAGE", "Cannot enforce or verify Windows credential ACLs; use environment authentication instead.");
236
+ }
237
+ }
238
+ async function securePath(path, directory, set) {
239
+ const stat = await lstat(path);
240
+ if (stat.isSymbolicLink() ||
241
+ (directory ? !stat.isDirectory() : !stat.isFile()))
242
+ throw new CliError("INSECURE_CREDENTIAL_STORAGE", "Credential paths must not be symbolic links or special files.");
243
+ if (process.platform === "win32")
244
+ return windowsPermissions(path, directory, set);
245
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
246
+ throw new CliError("INSECURE_CREDENTIAL_STORAGE", "Credential storage must be owned by the current user.");
247
+ const expected = directory ? 0o700 : 0o600;
248
+ if (set)
249
+ await chmod(path, expected);
250
+ else if ((stat.mode & 0o777) !== expected)
251
+ throw new CliError("INSECURE_CREDENTIAL_STORAGE", "Credential directory/file permissions must be 0700/0600.");
252
+ }
253
+ async function prepareDirectory() {
254
+ const { directory } = getConfigPaths();
255
+ await mkdir(directory, { recursive: true, mode: 0o700 });
256
+ await securePath(directory, true, true);
257
+ }
258
+ async function prepareAtomicWrite(path, content) {
259
+ const temporary = `${path}.${randomUUID()}.tmp`;
260
+ try {
261
+ try {
262
+ await securePath(path, false, false);
263
+ }
264
+ catch (error) {
265
+ if (!missing(error))
266
+ throw error;
267
+ }
268
+ const handle = await open(temporary, constants.O_WRONLY |
269
+ constants.O_CREAT |
270
+ constants.O_EXCL |
271
+ (constants.O_NOFOLLOW ?? 0), 0o600);
272
+ try {
273
+ await securePath(temporary, false, true);
274
+ await handle.writeFile(content, "utf8");
275
+ }
276
+ finally {
277
+ await handle.close();
278
+ }
279
+ return {
280
+ async commit() {
281
+ try {
282
+ await securePath(path, false, false);
283
+ }
284
+ catch (error) {
285
+ if (!missing(error))
286
+ throw error;
287
+ }
288
+ await rename(temporary, path);
289
+ },
290
+ async discard() {
291
+ await rm(temporary, { force: true });
292
+ },
293
+ };
294
+ }
295
+ catch (error) {
296
+ await rm(temporary, { force: true }).catch(() => { });
297
+ throw error;
298
+ }
299
+ }
300
+ async function atomicWrite(path, content) {
301
+ const prepared = await prepareAtomicWrite(path, content);
302
+ try {
303
+ await prepared.commit();
304
+ }
305
+ finally {
306
+ await prepared.discard();
307
+ }
308
+ }
309
+ export async function saveUserProject(input, projectRef) {
310
+ const apiUrl = normalizeApiUrl(input);
311
+ if (projectRef !== undefined &&
312
+ !/^[A-Za-z0-9_-]+\.[A-Za-z][A-Za-z0-9_-]*$/u.test(projectRef))
313
+ throw new CliError("INVALID_PROJECT", "Use a full team.KEY project ref.");
314
+ const config = await readUserConfig();
315
+ if (config.project_defaults?.[apiUrl] === projectRef)
316
+ return { changed: false };
317
+ if (projectRef === undefined) {
318
+ delete config.project_defaults?.[apiUrl];
319
+ if (Object.keys(config.project_defaults ?? {}).length === 0)
320
+ delete config.project_defaults;
321
+ }
322
+ else
323
+ config.project_defaults = {
324
+ ...config.project_defaults,
325
+ [apiUrl]: projectRef,
326
+ };
327
+ await prepareDirectory();
328
+ const prepared = await prepareAtomicWrite(getConfigPaths().config, stringify(config));
329
+ try {
330
+ await prepared.commit();
331
+ }
332
+ catch (error) {
333
+ await prepared.discard().catch(() => { });
334
+ throw error;
335
+ }
336
+ return { changed: true };
337
+ }
338
+ async function readCredentials() {
339
+ const { directory, credentials } = getConfigPaths();
340
+ if (!(await exists(credentials)))
341
+ return {};
342
+ await securePath(directory, true, false);
343
+ await securePath(credentials, false, false);
344
+ let parsed;
345
+ try {
346
+ parsed = JSON.parse(await readFile(credentials, "utf8"));
347
+ }
348
+ catch {
349
+ throw new CliError("INVALID_CREDENTIAL_STORAGE", "Cannot parse credential storage.");
350
+ }
351
+ if (!object(parsed) ||
352
+ Object.values(parsed).some((value) => typeof value !== "string" || !value))
353
+ throw new CliError("INVALID_CREDENTIAL_STORAGE", "Credential storage must map endpoints to nonempty keys.");
354
+ const result = {};
355
+ for (const [key, value] of Object.entries(parsed)) {
356
+ if (typeof value === "string") {
357
+ result[key] = value;
358
+ registerSecret(value);
359
+ }
360
+ }
361
+ return result;
362
+ }
363
+ async function keyringEntry(apiUrl) {
364
+ // The native OS backend may not exist on the host; defer loading so env/file auth still works.
365
+ try {
366
+ const { AsyncEntry } = await import("@napi-rs/keyring");
367
+ return new AsyncEntry("itsaplan-cli", apiUrl);
368
+ }
369
+ catch {
370
+ throw new CliError("SECURE_STORAGE_UNAVAILABLE", "OS credential storage is unavailable; use environment authentication or explicit --storage file.");
371
+ }
372
+ }
373
+ async function storedToken(apiUrl, storage) {
374
+ if (storage === "file")
375
+ return (await readCredentials())[apiUrl];
376
+ try {
377
+ const token = await (await keyringEntry(apiUrl)).getPassword();
378
+ if (token)
379
+ registerSecret(token);
380
+ return token;
381
+ }
382
+ catch {
383
+ throw new CliError("SECURE_STORAGE_UNAVAILABLE", "Cannot read OS credential storage; no file fallback was attempted. Use environment authentication or explicit --storage file.");
384
+ }
385
+ }
386
+ export async function saveAuth(input, token, storage = "keyring") {
387
+ const apiUrl = normalizeApiUrl(input);
388
+ registerSecret(token);
389
+ if (!token || /[\r\n\0]/u.test(token))
390
+ throw new CliError("INVALID_TOKEN", "API key must be nonempty and contain no line breaks.");
391
+ const config = await readUserConfig();
392
+ await prepareDirectory();
393
+ const paths = getConfigPaths();
394
+ const previous = config.credentials?.[apiUrl]?.storage;
395
+ config.api_url = apiUrl;
396
+ config.credentials = { ...config.credentials, [apiUrl]: { storage } };
397
+ // Complete the secure configuration write before touching either credential backend.
398
+ const prepared = await prepareAtomicWrite(paths.config, stringify(config));
399
+ try {
400
+ let save;
401
+ let restore;
402
+ let inspect;
403
+ if (storage === "file") {
404
+ const credentials = await readCredentials();
405
+ const existed = await exists(paths.credentials);
406
+ const original = existed
407
+ ? await readFile(paths.credentials, "utf8")
408
+ : undefined;
409
+ const updated = JSON.stringify({ ...credentials, [apiUrl]: token });
410
+ save = () => atomicWrite(paths.credentials, updated);
411
+ restore = async () => {
412
+ if (original === undefined)
413
+ await rm(paths.credentials, { force: true });
414
+ else
415
+ await atomicWrite(paths.credentials, original);
416
+ };
417
+ inspect = async () => {
418
+ const current = await readCredentials();
419
+ const restored = original === undefined
420
+ ? !(await exists(paths.credentials))
421
+ : (await readFile(paths.credentials, "utf8")) === original;
422
+ return { restored, credentialSaved: current[apiUrl] === token };
423
+ };
424
+ }
425
+ else {
426
+ const entry = await keyringEntry(apiUrl);
427
+ let original;
428
+ try {
429
+ // A failed read is not evidence of absence: never overwrite an unknown credential.
430
+ original = await entry.getPassword();
431
+ }
432
+ catch {
433
+ throw new CliError("SECURE_STORAGE_UNAVAILABLE", "Cannot read the existing OS credential; no credentials were changed.");
434
+ }
435
+ if (original !== undefined)
436
+ registerSecret(original);
437
+ save = () => entry.setPassword(token);
438
+ restore = async () => {
439
+ if (original === undefined)
440
+ await entry.deleteCredential();
441
+ else
442
+ await entry.setPassword(original);
443
+ };
444
+ inspect = async () => {
445
+ const current = await entry.getPassword();
446
+ return {
447
+ restored: current === original,
448
+ credentialSaved: current === token,
449
+ };
450
+ };
451
+ }
452
+ try {
453
+ await save();
454
+ await prepared.commit();
455
+ }
456
+ catch {
457
+ // Native methods can throw after applying; only readback confirms restoration.
458
+ await restore().catch(() => { });
459
+ const state = await inspect().catch(() => undefined);
460
+ const rollbackConfirmed = state?.restored === true;
461
+ const recovery = storage === "file"
462
+ ? {
463
+ path: paths.credentials,
464
+ endpoint: apiUrl,
465
+ instruction: "Inspect this endpoint in the credential file; restore its previous key or remove only this endpoint. Preserve other endpoints. The saved config was not changed.",
466
+ }
467
+ : {
468
+ service: "itsaplan-cli",
469
+ account: apiUrl,
470
+ instruction: "Use the OS credential manager to inspect this service/account and restore its previous key or remove a newly created entry. The saved config was not changed; auth status/logout cannot find an entry without a config pointer.",
471
+ };
472
+ throw new CliError("LOCAL_AUTH_INCOMPLETE", rollbackConfirmed
473
+ ? "Authentication was not saved; the previous credential state was restored and configuration was not changed."
474
+ : "Authentication could not be saved and credential rollback could not be confirmed; inspect the recovery location before retrying.", rollbackConfirmed ? 1 : 6, {
475
+ outcome: rollbackConfirmed
476
+ ? "not_applied"
477
+ : state?.credentialSaved
478
+ ? "applied"
479
+ : "unknown",
480
+ verification: !rollbackConfirmed && state?.credentialSaved ? "failed" : "not_run",
481
+ retryable: false,
482
+ data: {
483
+ action: "login",
484
+ apiUrl,
485
+ storage,
486
+ credentialSaved: rollbackConfirmed ? false : state?.credentialSaved,
487
+ configSaved: false,
488
+ rollbackConfirmed,
489
+ ...(!rollbackConfirmed && { recovery }),
490
+ },
491
+ });
492
+ }
493
+ if (previous && previous !== storage) {
494
+ try {
495
+ await deleteStored(apiUrl, previous);
496
+ }
497
+ catch {
498
+ throw new CliError("LOCAL_AUTH_INCOMPLETE", "The new credential and configuration were saved, but previous credential cleanup failed; inspect auth status before retrying.", 6, {
499
+ outcome: "applied",
500
+ verification: "failed",
501
+ retryable: false,
502
+ data: {
503
+ action: "login",
504
+ apiUrl,
505
+ storage,
506
+ credentialSaved: true,
507
+ configSaved: true,
508
+ },
509
+ });
510
+ }
511
+ }
512
+ }
513
+ finally {
514
+ // A nonsecret temporary config cleanup failure must not mask credential recovery details.
515
+ await prepared.discard().catch(() => { });
516
+ }
517
+ }
518
+ async function deleteStored(apiUrl, storage) {
519
+ if (storage === "file") {
520
+ const credentials = await readCredentials();
521
+ const existed = apiUrl in credentials;
522
+ delete credentials[apiUrl];
523
+ await atomicWrite(getConfigPaths().credentials, JSON.stringify(credentials));
524
+ return existed;
525
+ }
526
+ const entry = await keyringEntry(apiUrl);
527
+ try {
528
+ return await entry.deleteCredential();
529
+ }
530
+ catch {
531
+ throw new CliError("LOCAL_WRITE_OUTCOME_UNKNOWN", "OS credential storage did not confirm removal; inspect auth status before retrying.", 6, {
532
+ outcome: "unknown",
533
+ verification: "not_run",
534
+ retryable: false,
535
+ data: { action: "logout", apiUrl, storage },
536
+ });
537
+ }
538
+ }
539
+ export async function removeAuth(input) {
540
+ const apiUrl = normalizeApiUrl(input);
541
+ const config = await readUserConfig();
542
+ const storage = config.credentials?.[apiUrl]?.storage;
543
+ if (!storage)
544
+ return { removed: false };
545
+ await prepareDirectory();
546
+ const removed = await deleteStored(apiUrl, storage);
547
+ delete config.credentials?.[apiUrl];
548
+ try {
549
+ await atomicWrite(getConfigPaths().config, stringify(config));
550
+ }
551
+ catch (error) {
552
+ if (!removed)
553
+ throw error;
554
+ throw new CliError("LOCAL_AUTH_INCOMPLETE", "The credential was removed, but configuration cleanup failed; inspect local authentication before retrying.", 6, {
555
+ outcome: "applied",
556
+ verification: "failed",
557
+ retryable: false,
558
+ data: {
559
+ action: "logout",
560
+ apiUrl,
561
+ storage,
562
+ removed: true,
563
+ configSaved: false,
564
+ },
565
+ });
566
+ }
567
+ return { removed, storage };
568
+ }
569
+ export async function createContext(options) {
570
+ // Inspect the repository even when --project overrides its project, never its trust boundary.
571
+ const project = await resolveProject(options);
572
+ const envToken = process.env.ITSAPLAN_API_KEY;
573
+ if ((envToken !== undefined || options.apiKey !== undefined) &&
574
+ options.apiUrl === undefined &&
575
+ !process.env.ITSAPLAN_URL)
576
+ throw new CliError("EXPLICIT_ENDPOINT_REQUIRED", "Environment and one-off API keys require ITSAPLAN_URL or --api-url; a saved default is not a trusted destination.");
577
+ const apiUrl = await resolveApiUrl(options);
578
+ if (!apiUrl)
579
+ throw new CliError("MISSING_API_URL", "Set ITSAPLAN_URL, use --api-url, or run auth login.");
580
+ let token;
581
+ let source = "missing";
582
+ let storage;
583
+ if (envToken !== undefined) {
584
+ token = envToken;
585
+ source = "env";
586
+ }
587
+ else {
588
+ storage = (await readUserConfig()).credentials?.[apiUrl]?.storage;
589
+ if (storage) {
590
+ token = await storedToken(apiUrl, storage);
591
+ if (token)
592
+ source = "config";
593
+ }
594
+ if (!token && options.apiKey !== undefined) {
595
+ token = options.apiKey;
596
+ source = "flag";
597
+ storage = undefined;
598
+ }
599
+ }
600
+ if (token !== undefined && (!token || /[\r\n\0]/u.test(token)))
601
+ throw new CliError("INVALID_TOKEN", "API key must be nonempty and contain no line breaks.");
602
+ return {
603
+ options,
604
+ project,
605
+ client: new HttpClient(apiUrl, token),
606
+ auth: { apiUrl, source, storage, tokenAvailable: Boolean(token) },
607
+ };
608
+ }
@@ -0,0 +1,47 @@
1
+ export type ObjectData = Record<string, unknown>;
2
+ export interface GlobalOptions {
3
+ json?: boolean;
4
+ apiUrl?: string;
5
+ apiKey?: string;
6
+ project?: string;
7
+ }
8
+ export interface Actor {
9
+ kind: "agent" | "human";
10
+ name?: "codex" | "claude";
11
+ source?: "flag" | "env";
12
+ }
13
+ export interface Result {
14
+ data: unknown;
15
+ meta?: ObjectData;
16
+ outcome?: "applied" | "not_applied" | "unknown";
17
+ verification?: "passed" | "failed" | "not_run";
18
+ exitCode?: number;
19
+ error?: ObjectData;
20
+ }
21
+ export declare class CliError extends Error {
22
+ code: string;
23
+ exitCode: number;
24
+ details: ObjectData;
25
+ constructor(code: string, message: string, exitCode?: number, details?: ObjectData);
26
+ }
27
+ export interface RequestOptions {
28
+ method?: string;
29
+ body?: unknown;
30
+ query?: Record<string, string | number | boolean | undefined>;
31
+ }
32
+ export interface Client {
33
+ request(path: string, options?: RequestOptions): Promise<unknown>;
34
+ }
35
+ export interface Context {
36
+ client: Client;
37
+ options: GlobalOptions;
38
+ project?: string;
39
+ auth: {
40
+ apiUrl: string;
41
+ source: string;
42
+ storage?: string;
43
+ tokenAvailable: boolean;
44
+ };
45
+ }
46
+ export declare const CLI_VERSION: string;
47
+ export declare const API_BASELINE = "1.9.0";