i-repo 2.3.0 → 2.5.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.
@@ -6,19 +6,24 @@
6
6
  import { Command } from "commander";
7
7
  import { input, password as passwordPrompt, select, confirm } from "@inquirer/prompts";
8
8
  import { IReporterClient } from "../sdk/index.js";
9
- import { getConfig, getConfigWithSource, setConfig, deleteConfig, } from "../config/store.js";
10
- import { getUserConfigPath, getProjectConfigPath, getLocalConfigPath, getConfigPath, findProjectRoot, } from "../config/scoped-store.js";
9
+ import { getConfig, getConfigWithSource, clearStoredCredentials, } from "../config/store.js";
10
+ import { getUserConfigPath, getProjectConfigPath, getLocalConfigPath, findProjectRoot, } from "../config/scoped-store.js";
11
+ import { assertEndpointAllowed, persistCredentials } from "../config/client.js";
11
12
  import { logHttp } from "../core/logger.js";
12
13
  import { printSuccess, printError, printWarning, printInfo, printDetails, printInteractionHint, withSpinner, t, } from "../ui/index.js";
13
14
  import { t as tr } from "../i18n/index.js";
14
15
  function saveCredentials(endpoint, user, password, scope, savePassword) {
15
- setConfig("endpoint", endpoint, scope);
16
- setConfig("user", user, scope);
17
- if (savePassword) {
18
- setConfig("password", password, scope);
19
- }
20
- const savedPath = getConfigPath(scope);
16
+ const { savedPath, redirectedToUser, userPath } = persistCredentials({
17
+ endpoint,
18
+ user,
19
+ password,
20
+ scope,
21
+ savePassword,
22
+ });
21
23
  printInfo(tr("auth.savedTo", { path: savedPath }));
24
+ if (redirectedToUser) {
25
+ printInfo(tr("auth.userOnlyRedirected", { path: userPath }));
26
+ }
22
27
  if (!savePassword) {
23
28
  printInfo(tr("auth.passwordNotStored"));
24
29
  }
@@ -47,7 +52,15 @@ authCommand
47
52
  if (!endpoint) {
48
53
  endpoint = await input({
49
54
  message: tr("auth.endpoint"),
50
- validate: (v) => v.startsWith("http") || tr("auth.validUrl"),
55
+ validate: (v) => {
56
+ try {
57
+ const u = new URL(v);
58
+ return u.protocol === "https:" || u.protocol === "http:" || tr("auth.validUrl");
59
+ }
60
+ catch {
61
+ return tr("auth.validUrl");
62
+ }
63
+ },
51
64
  });
52
65
  }
53
66
  if (!user) {
@@ -63,6 +76,8 @@ authCommand
63
76
  validate: (v) => v.length > 0 || tr("auth.passwordRequired"),
64
77
  });
65
78
  }
79
+ // Block plain-http / non-https endpoints before sending credentials.
80
+ assertEndpointAllowed(endpoint);
66
81
  // Authenticate via login() and verify with a lightweight API call
67
82
  await withSpinner("authenticating", async () => {
68
83
  const client = new IReporterClient({
@@ -124,7 +139,9 @@ authCommand
124
139
  .description("Clear stored credentials")
125
140
  .action(() => {
126
141
  try {
127
- deleteConfig("user");
142
+ // Remove BOTH user and password from every scope (shared with the Ink
143
+ // logout executor so the two surfaces never diverge).
144
+ clearStoredCredentials();
128
145
  printSuccess(tr("auth.logoutSuccess"));
129
146
  }
130
147
  catch (error) {
@@ -6,7 +6,7 @@
6
6
  import { Command } from "commander";
7
7
  import { getConfigWithSource, setConfig, resetConfig, validKeys, isValidKey, } from "../config/store.js";
8
8
  import { parseDisabledCommands, isConfigPath } from "../config/command-gate.js";
9
- import { getAllConfigPaths, } from "../config/scoped-store.js";
9
+ import { getAllConfigPaths, USER_ONLY_KEYS, } from "../config/scoped-store.js";
10
10
  import { setLocale, t as tr } from "../i18n/index.js";
11
11
  import { printSuccess, printError, printWarning, printDetails, printInfo, out, t, } from "../ui/index.js";
12
12
  const validScopes = ["user", "project", "local"];
@@ -82,6 +82,11 @@ configCommand
82
82
  if (key === "password") {
83
83
  printWarning(tr("config.passwordWarning"));
84
84
  }
85
+ // A user-only key written to project/local is silently ignored at
86
+ // runtime (see USER_ONLY_KEYS) — warn so the value isn't trusted to apply.
87
+ if (USER_ONLY_KEYS.has(key) && opts.scope !== "user") {
88
+ printWarning(tr("config.userOnlyScopeWarning", { key, scope: opts.scope }));
89
+ }
85
90
  const coerced = coerceValue(key, value);
86
91
  if (coerced === null) {
87
92
  process.exitCode = 1;
@@ -91,13 +91,26 @@ pluginCommand
91
91
  const row = buildDescribeRow(PLUGIN_PREFIX + name, bin, declared);
92
92
  // 人間面 (table/csv) では params を読める1行に整形 (ndjson/json は宣言そのまま —
93
93
  // render と同じ「ラベル化は人間面のみ」の流儀)
94
- if ((format === "table" || format === "csv") && Array.isArray(row.params)) {
95
- row.params = row.params
96
- .map((p) => {
97
- const choices = Array.isArray(p.choices) ? `:${p.choices.join("|")}` : "";
98
- return `${String(p.name)} <${String(p.type)}${choices}>${p.required ? " (required)" : ""}`;
99
- })
100
- .join(", ");
94
+ if (format === "table" || format === "csv") {
95
+ if (Array.isArray(row.params)) {
96
+ // label は GUI の関心事 — CLI は実フラグ名を見せる
97
+ row.params = row.params
98
+ .map((p) => {
99
+ const choices = Array.isArray(p.choices) ? `:${p.choices.join("|")}` : "";
100
+ const markers = `${p.required ? " (required)" : ""}${p.secret ? " (secret)" : ""}` +
101
+ `${typeof p.subcommand === "string" ? ` [${p.subcommand}]` : ""}`;
102
+ return `${String(p.name)} <${String(p.type)}${choices}>${markers}`;
103
+ })
104
+ .join(", ");
105
+ }
106
+ if (Array.isArray(row.platforms)) {
107
+ row.platforms = row.platforms.join(", ");
108
+ }
109
+ if (Array.isArray(row.subcommands)) {
110
+ row.subcommands = row.subcommands
111
+ .map((s) => (s.description ? `${String(s.name)} (${String(s.description)})` : String(s.name)))
112
+ .join(", ");
113
+ }
101
114
  }
102
115
  formatOutput(row, {
103
116
  format,
@@ -13,11 +13,64 @@
13
13
  * 6. TTY prompt: interactive password input (if terminal is attached)
14
14
  */
15
15
  import { IReporterClient } from "../sdk/index.js";
16
+ import type { ConfigScope } from "./scoped-store.js";
16
17
  export interface CreateClientOptions {
17
18
  endpoint?: string;
18
19
  user?: string;
19
20
  password?: string;
20
21
  }
22
+ /** Env var that opts in to plain-http on ANY host (including public ones). */
23
+ export declare const INSECURE_ENV = "IREPO_ALLOW_INSECURE";
24
+ /** Whether the user has explicitly opted in to insecure (plain http) endpoints. */
25
+ export declare function isInsecureAllowed(): boolean;
26
+ /**
27
+ * Whether a host is on the local machine or a private/internal network, where
28
+ * plain http is the common (and far lower risk) reality for on-prem i-Reporter
29
+ * servers. Covers loopback, RFC1918 private IPv4, link-local, IPv6
30
+ * loopback/unique-local/link-local, *.local (mDNS), *.localhost, and bare
31
+ * single-label intranet hostnames (which can't be reached from the public
32
+ * internet without a search domain).
33
+ */
34
+ export declare function isPrivateOrLocalHost(hostname: string): boolean;
35
+ /**
36
+ * Validate that an endpoint is safe to send credentials to.
37
+ *
38
+ * Plain http sends the Login user/password and the session cookie in clear
39
+ * text, readable by any passive eavesdropper on the path. Policy:
40
+ * - https → always allowed.
41
+ * - http to a local/private host (loopback, RFC1918, *.local, intranet name)
42
+ * → allowed with a warning. On-prem i-Reporter servers
43
+ * commonly run plain http on the internal LAN.
44
+ * - http to a public host → rejected, unless IREPO_ALLOW_INSECURE=1 (warned).
45
+ * - any other scheme (file:, data:, ...) → always rejected.
46
+ * Throws ConfigError so the failure is classified (config-error exit code).
47
+ */
48
+ export declare function assertEndpointAllowed(endpoint: string): void;
49
+ export interface PersistCredentialsResult {
50
+ /** Path of the requested scope, where the shareable `user` was written. */
51
+ savedPath: string | undefined;
52
+ /** endpoint/password were forced to the user scope (requested scope ≠ user). */
53
+ redirectedToUser: boolean;
54
+ /** The user-scope config path (where endpoint/password actually live). */
55
+ userPath: string;
56
+ }
57
+ /**
58
+ * Persist login credentials, honoring USER_ONLY_KEYS. `endpoint` and `password`
59
+ * only take effect from the user scope at runtime, so they are ALWAYS written
60
+ * there — writing them to project/local would create config that silently does
61
+ * nothing, and a project-scoped password would risk a plaintext VCS leak. Only
62
+ * the shareable, non-sensitive `user` goes to the requested scope.
63
+ *
64
+ * Shared by the CLI `auth login` and the Ink AuthLoginScreen so both surfaces
65
+ * apply the same trust policy.
66
+ */
67
+ export declare function persistCredentials(args: {
68
+ endpoint: string;
69
+ user: string;
70
+ password: string;
71
+ scope: ConfigScope;
72
+ savePassword: boolean;
73
+ }): PersistCredentialsResult;
21
74
  /**
22
75
  * Create an IReporterClient, resolving credentials from
23
76
  * CLI flags -> environment variables -> config store (scoped) -> interactive prompt.
@@ -16,7 +16,120 @@ import { IReporterClient } from "../sdk/index.js";
16
16
  import { password as passwordPrompt } from "@inquirer/prompts";
17
17
  import { logHttp } from "../core/logger.js";
18
18
  import { ConfigError } from "../core/errors.js";
19
- import { getConfig } from "./store.js";
19
+ import { getConfig, setConfig } from "./store.js";
20
+ import { getConfigPath, getUserConfigPath } from "./scoped-store.js";
21
+ /** Env var that opts in to plain-http on ANY host (including public ones). */
22
+ export const INSECURE_ENV = "IREPO_ALLOW_INSECURE";
23
+ /** Whether the user has explicitly opted in to insecure (plain http) endpoints. */
24
+ export function isInsecureAllowed() {
25
+ const v = process.env[INSECURE_ENV];
26
+ return v === "1" || v?.toLowerCase() === "true";
27
+ }
28
+ /**
29
+ * Whether a host is on the local machine or a private/internal network, where
30
+ * plain http is the common (and far lower risk) reality for on-prem i-Reporter
31
+ * servers. Covers loopback, RFC1918 private IPv4, link-local, IPv6
32
+ * loopback/unique-local/link-local, *.local (mDNS), *.localhost, and bare
33
+ * single-label intranet hostnames (which can't be reached from the public
34
+ * internet without a search domain).
35
+ */
36
+ export function isPrivateOrLocalHost(hostname) {
37
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 [] brackets
38
+ if (h === "localhost" || h.endsWith(".localhost"))
39
+ return true;
40
+ if (h.endsWith(".local"))
41
+ return true;
42
+ // bare single-label hostname (no dots) — an intranet name, not a public FQDN
43
+ if (!h.includes(".") && !h.includes(":"))
44
+ return true;
45
+ if (h === "::1")
46
+ return true; // IPv6 loopback
47
+ if (/^f[cd][0-9a-f]{2}:/.test(h))
48
+ return true; // IPv6 unique-local fc00::/7
49
+ if (h.startsWith("fe80:"))
50
+ return true; // IPv6 link-local
51
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
52
+ if (m) {
53
+ const a = Number(m[1]);
54
+ const b = Number(m[2]);
55
+ if (a === 127)
56
+ return true; // loopback 127/8
57
+ if (a === 10)
58
+ return true; // 10/8
59
+ if (a === 192 && b === 168)
60
+ return true; // 192.168/16
61
+ if (a === 172 && b >= 16 && b <= 31)
62
+ return true; // 172.16/12
63
+ if (a === 169 && b === 254)
64
+ return true; // link-local 169.254/16
65
+ }
66
+ return false;
67
+ }
68
+ /**
69
+ * Validate that an endpoint is safe to send credentials to.
70
+ *
71
+ * Plain http sends the Login user/password and the session cookie in clear
72
+ * text, readable by any passive eavesdropper on the path. Policy:
73
+ * - https → always allowed.
74
+ * - http to a local/private host (loopback, RFC1918, *.local, intranet name)
75
+ * → allowed with a warning. On-prem i-Reporter servers
76
+ * commonly run plain http on the internal LAN.
77
+ * - http to a public host → rejected, unless IREPO_ALLOW_INSECURE=1 (warned).
78
+ * - any other scheme (file:, data:, ...) → always rejected.
79
+ * Throws ConfigError so the failure is classified (config-error exit code).
80
+ */
81
+ export function assertEndpointAllowed(endpoint) {
82
+ let url;
83
+ try {
84
+ url = new URL(endpoint);
85
+ }
86
+ catch {
87
+ throw new ConfigError(`Invalid API endpoint URL: ${endpoint}\n` +
88
+ " Expected an absolute URL, e.g. https://host/ConMasAPI/Rests/APIExecute.aspx");
89
+ }
90
+ if (url.protocol === "https:")
91
+ return;
92
+ if (url.protocol === "http:") {
93
+ if (isPrivateOrLocalHost(url.hostname)) {
94
+ process.stderr.write(`Warning: using plain HTTP to ${url.origin} — credentials are not encrypted. ` +
95
+ "Acceptable on a trusted local/private network; prefer https where available.\n");
96
+ return;
97
+ }
98
+ if (isInsecureAllowed()) {
99
+ process.stderr.write(`Warning: sending credentials over plain HTTP to the public host ${url.origin} ` +
100
+ `(${INSECURE_ENV} is set). Traffic can be intercepted on the network.\n`);
101
+ return;
102
+ }
103
+ throw new ConfigError(`Refusing to send credentials over plain http to a public host: ${endpoint}\n` +
104
+ " They would be transmitted in clear text and can be intercepted.\n" +
105
+ " Use an https:// endpoint. Local/private hosts (localhost, 10.x, 192.168.x, *.local)\n" +
106
+ ` are allowed over http; for a public http host set ${INSECURE_ENV}=1 to override (not recommended).`);
107
+ }
108
+ throw new ConfigError(`Unsupported endpoint scheme "${url.protocol}": ${endpoint}\n` +
109
+ ` Only https:// (or http:// to a local/private host) is allowed.`);
110
+ }
111
+ /**
112
+ * Persist login credentials, honoring USER_ONLY_KEYS. `endpoint` and `password`
113
+ * only take effect from the user scope at runtime, so they are ALWAYS written
114
+ * there — writing them to project/local would create config that silently does
115
+ * nothing, and a project-scoped password would risk a plaintext VCS leak. Only
116
+ * the shareable, non-sensitive `user` goes to the requested scope.
117
+ *
118
+ * Shared by the CLI `auth login` and the Ink AuthLoginScreen so both surfaces
119
+ * apply the same trust policy.
120
+ */
121
+ export function persistCredentials(args) {
122
+ setConfig("user", args.user, args.scope);
123
+ setConfig("endpoint", args.endpoint, "user");
124
+ if (args.savePassword) {
125
+ setConfig("password", args.password, "user");
126
+ }
127
+ return {
128
+ savedPath: getConfigPath(args.scope),
129
+ redirectedToUser: args.scope !== "user",
130
+ userPath: getUserConfigPath(),
131
+ };
132
+ }
20
133
  // In-memory password cache for the CLI session lifetime.
21
134
  let cachedPassword;
22
135
  /**
@@ -34,6 +147,8 @@ export async function createClient(opts = {}) {
34
147
  ' Run "i-repo config set endpoint <url>"\n' +
35
148
  " or set IREPO_ENDPOINT environment variable.");
36
149
  }
150
+ // Block plain-http / non-https endpoints before any credential is sent.
151
+ assertEndpointAllowed(endpoint);
37
152
  if (!user) {
38
153
  throw new ConfigError("User is not configured.\n" +
39
154
  ' Run "i-repo config set user <name>"\n' +
@@ -5,6 +5,18 @@ export interface ConfigWithSource<K extends keyof IRepoConfig = keyof IRepoConfi
5
5
  source: ConfigScope | undefined;
6
6
  path: string | undefined;
7
7
  }
8
+ /**
9
+ * Keys that route credentials or relax safety gates. These are read ONLY from
10
+ * the user scope (plus env vars / CLI flags handled at call sites). A
11
+ * checked-out repository's project/local config must NOT be able to silently:
12
+ * - redirect credentials to another endpoint (`endpoint`)
13
+ * - ship a shared plaintext password (`password`)
14
+ * - forward extra parent-env vars to plugin processes (`pluginPassEnv`)
15
+ * - loosen the destructive-delete gate (`allowDelete`)
16
+ * `disabledCommands` is intentionally NOT here: it can only ADD restrictions
17
+ * (never loosen), and team-shared command hiding is a legitimate use.
18
+ */
19
+ export declare const USER_ONLY_KEYS: ReadonlySet<keyof IRepoConfig>;
8
20
  /** ~/.i-repo/i-repo.json */
9
21
  export declare function getUserConfigPath(): string;
10
22
  /** ~/.i-repo/ */
@@ -34,6 +46,8 @@ export declare function writeScopedConfig(scope: ConfigScope, data: Partial<IRep
34
46
  * Priority: local > project > user
35
47
  */
36
48
  export declare function getMergedConfig(): Partial<IRepoConfig>;
49
+ /** Test seam: reset the per-process shadow-warning memo. */
50
+ export declare function __resetUserOnlyShadowMemo(): void;
37
51
  /**
38
52
  * Get a config value with source information.
39
53
  * Checks scopes from highest to lowest priority.
@@ -11,6 +11,23 @@
11
11
  import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, unlinkSync } from "node:fs";
12
12
  import { join, dirname, resolve } from "node:path";
13
13
  import { homedir } from "node:os";
14
+ /**
15
+ * Keys that route credentials or relax safety gates. These are read ONLY from
16
+ * the user scope (plus env vars / CLI flags handled at call sites). A
17
+ * checked-out repository's project/local config must NOT be able to silently:
18
+ * - redirect credentials to another endpoint (`endpoint`)
19
+ * - ship a shared plaintext password (`password`)
20
+ * - forward extra parent-env vars to plugin processes (`pluginPassEnv`)
21
+ * - loosen the destructive-delete gate (`allowDelete`)
22
+ * `disabledCommands` is intentionally NOT here: it can only ADD restrictions
23
+ * (never loosen), and team-shared command hiding is a legitimate use.
24
+ */
25
+ export const USER_ONLY_KEYS = new Set([
26
+ "endpoint",
27
+ "password",
28
+ "pluginPassEnv",
29
+ "allowDelete",
30
+ ]);
14
31
  // ── Paths ────────────────────────────────────────────────────
15
32
  const CONFIG_DIR_NAME = ".i-repo";
16
33
  const CONFIG_FILE_NAME = "i-repo.json";
@@ -191,13 +208,60 @@ export function getMergedConfig() {
191
208
  const user = readScopedConfig("user");
192
209
  const project = readScopedConfig("project");
193
210
  const local = readScopedConfig("local");
194
- return { ...user, ...project, ...local };
211
+ const merged = { ...user, ...project, ...local };
212
+ // user-only keys: the effective value is the user-scope value alone, so the
213
+ // merged view (config list) reflects what actually takes effect at runtime.
214
+ for (const key of USER_ONLY_KEYS) {
215
+ if (key in user && user[key] !== undefined) {
216
+ merged[key] = user[key];
217
+ }
218
+ else {
219
+ delete merged[key];
220
+ }
221
+ }
222
+ return merged;
223
+ }
224
+ // A user-only key shadowed by project/local is reported once per key per
225
+ // process (getConfigWithSource is called many times per command).
226
+ const userOnlyShadowChecked = new Set();
227
+ /** Resolve a user-only key from the user scope alone, warning if a lower-trust
228
+ * scope tried (and failed) to set it. */
229
+ function getUserScopeOnly(key) {
230
+ if (!userOnlyShadowChecked.has(key)) {
231
+ userOnlyShadowChecked.add(key);
232
+ for (const scope of ["project", "local"]) {
233
+ const p = getConfigPath(scope);
234
+ if (!p)
235
+ continue;
236
+ const c = readJsonFile(p);
237
+ if (key in c && c[key] !== undefined) {
238
+ console.error(`Warning: ignoring "${key}" from ${scope} config (${p}) — for security this ` +
239
+ `key is only honored from your user config. A checked-out repository must not ` +
240
+ `redirect credentials or relax safety gates. ` +
241
+ `Set it with: i-repo config set ${key} <value> --scope user`);
242
+ }
243
+ }
244
+ }
245
+ const userPath = getUserConfigPath();
246
+ const user = readJsonFile(userPath);
247
+ if (key in user && user[key] !== undefined) {
248
+ return { value: user[key], source: "user", path: userPath };
249
+ }
250
+ return { value: undefined, source: undefined, path: undefined };
251
+ }
252
+ /** Test seam: reset the per-process shadow-warning memo. */
253
+ export function __resetUserOnlyShadowMemo() {
254
+ userOnlyShadowChecked.clear();
195
255
  }
196
256
  /**
197
257
  * Get a config value with source information.
198
258
  * Checks scopes from highest to lowest priority.
199
259
  */
200
260
  export function getConfigWithSource(key) {
261
+ // Credential-routing / safety-gate keys: user scope only (ignore project/local).
262
+ if (USER_ONLY_KEYS.has(key)) {
263
+ return getUserScopeOnly(key);
264
+ }
201
265
  // Check local first (highest priority)
202
266
  const localPath = getLocalConfigPath();
203
267
  if (localPath) {
@@ -31,6 +31,13 @@ export declare function getConfigWithSource<K extends keyof IRepoConfig>(key: K)
31
31
  export declare function setConfig<K extends keyof IRepoConfig>(key: K, value: IRepoConfig[K], scope?: ConfigScope): void;
32
32
  export declare function deleteConfig<K extends keyof IRepoConfig>(key: K, scope?: ConfigScope): void;
33
33
  export declare function resetConfig(scope?: ConfigScope): void;
34
+ /**
35
+ * Clear stored credentials (user AND password) from every scope that has a
36
+ * config file. Shared by the CLI `auth logout` and the Ink logout executor so
37
+ * both surfaces behave identically — leaving the password behind while
38
+ * reporting a successful logout is misleading and a credential-removal gap.
39
+ */
40
+ export declare function clearStoredCredentials(): void;
34
41
  export declare function getAllConfig(): Partial<IRepoConfig>;
35
42
  export declare const validKeys: (keyof IRepoConfig)[];
36
43
  export declare function isValidKey(key: string): key is keyof IRepoConfig;
@@ -6,7 +6,8 @@
6
6
  * < Project (<repo>/.i-repo/i-repo.json)
7
7
  * < Local (<repo>/.i-repo/i-repo.local.json)
8
8
  */
9
- import { getMergedConfig, getConfigWithSource as scopedGetConfigWithSource, setScopedConfig, deleteScopedConfig, resetScopedConfig, } from "./scoped-store.js";
9
+ import { existsSync } from "node:fs";
10
+ import { getMergedConfig, getConfigWithSource as scopedGetConfigWithSource, setScopedConfig, deleteScopedConfig, resetScopedConfig, getAllConfigPaths, } from "./scoped-store.js";
10
11
  export function getConfig(key) {
11
12
  return scopedGetConfigWithSource(key).value;
12
13
  }
@@ -22,6 +23,20 @@ export function deleteConfig(key, scope = "user") {
22
23
  export function resetConfig(scope = "user") {
23
24
  resetScopedConfig(scope);
24
25
  }
26
+ /**
27
+ * Clear stored credentials (user AND password) from every scope that has a
28
+ * config file. Shared by the CLI `auth logout` and the Ink logout executor so
29
+ * both surfaces behave identically — leaving the password behind while
30
+ * reporting a successful logout is misleading and a credential-removal gap.
31
+ */
32
+ export function clearStoredCredentials() {
33
+ for (const { scope, path } of getAllConfigPaths()) {
34
+ if (!path || !existsSync(path))
35
+ continue;
36
+ deleteScopedConfig("user", scope);
37
+ deleteScopedConfig("password", scope);
38
+ }
39
+ }
25
40
  export function getAllConfig() {
26
41
  return getMergedConfig();
27
42
  }
@@ -10,6 +10,7 @@
10
10
  import { appendFileSync, chmodSync, mkdirSync, existsSync, readdirSync, unlinkSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { getUserConfigDir } from "../config/scoped-store.js";
13
+ import { sanitizeForLog } from "../ui/width.js";
13
14
  const LOG_DIR_NAME = "logs";
14
15
  const MAX_LOG_FILES = 7;
15
16
  /** Get the log directory path: ~/.i-repo/logs/ */
@@ -63,7 +64,11 @@ function appendLines(lines) {
63
64
  try {
64
65
  ensureLogDir();
65
66
  const logPath = getLogFilePath();
66
- appendFileSync(logPath, lines.join("\n") + "\n", {
67
+ // Neutralize terminal escapes centrally so EVERY entry type (HTTP/ERROR/…),
68
+ // including server-derived messages/remarks/statusText/stacks, is inert when
69
+ // the log is viewed later. Keeps \n/\t so the log stays readable.
70
+ const safeLines = lines.map((line) => sanitizeForLog(line));
71
+ appendFileSync(logPath, safeLines.join("\n") + "\n", {
67
72
  encoding: "utf-8",
68
73
  mode: 0o600,
69
74
  });
@@ -54,7 +54,10 @@ export const en = {
54
54
  passwordRequired: "Password is required",
55
55
  authenticatedSuccessfully: "Authenticated successfully",
56
56
  authenticationFailed: "Authentication failed",
57
- logoutSuccess: "Logged out successfully. Stored user cleared.",
57
+ logoutSuccess: "Logged out successfully. Stored user and password cleared.",
58
+ userOnlyRedirected: "Note: endpoint and password were saved to your user config ({path}).\n" +
59
+ " For security they only take effect from the user scope (a checked-out\n" +
60
+ " repository must not redirect credentials), so they are not written to project/local.",
58
61
  statusTitle: "Connection Info",
59
62
  notConfigured: 'Not configured. Run "i-repo auth login" to get started.',
60
63
  notConfiguredInk: "Not configured. Run auth login to get started.",
@@ -159,6 +162,8 @@ export const en = {
159
162
  invalidFormat: "Invalid format. Must be one of: table, json, csv, ndjson",
160
163
  invalidTimeout: "Invalid timeout. Must be a positive number (seconds).",
161
164
  invalidBoolean: "Invalid value for {key}. Must be true or false.",
165
+ userOnlyScopeWarning: '"{key}" is only read from the user scope for security; a value in {scope} ' +
166
+ "scope is ignored at runtime. Use --scope user to make it take effect.",
162
167
  keyNotSet: "{key} is not set",
163
168
  configTitle: "Configuration",
164
169
  resetSuccess: "Configuration reset to defaults [{scope}]",
@@ -54,7 +54,10 @@ export const ja = {
54
54
  passwordRequired: "パスワードは必須です",
55
55
  authenticatedSuccessfully: "認証に成功しました",
56
56
  authenticationFailed: "認証に失敗しました",
57
- logoutSuccess: "ログアウトしました。保存されたユーザー情報をクリアしました。",
57
+ logoutSuccess: "ログアウトしました。保存されたユーザー情報とパスワードをクリアしました。",
58
+ userOnlyRedirected: "注意: エンドポイントとパスワードは user 設定 ({path}) に保存しました。\n" +
59
+ " セキュリティ上これらは user スコープからのみ有効なため (clone したリポジトリが\n" +
60
+ " 認証情報をすり替えられないように)、project/local には書き込みません。",
58
61
  statusTitle: "接続情報",
59
62
  notConfigured: '未設定です。"i-repo auth login" を実行して開始してください。',
60
63
  notConfiguredInk: "未設定です。auth login を実行して開始してください。",
@@ -159,6 +162,8 @@ export const ja = {
159
162
  invalidFormat: "無効な形式です。table, json, csv, ndjson のいずれかを指定してください",
160
163
  invalidTimeout: "無効なタイムアウト値です。正の数を指定してください(秒)。",
161
164
  invalidBoolean: "{key} の値が無効です。true または false を指定してください。",
165
+ userOnlyScopeWarning: "セキュリティ上「{key}」は user スコープからのみ読み込まれます。{scope} スコープの値は" +
166
+ "実行時に無視されます。有効にするには --scope user を使用してください。",
162
167
  keyNotSet: "{key} は未設定です",
163
168
  configTitle: "設定",
164
169
  resetSuccess: "設定をデフォルトにリセットしました [{scope}]",
@@ -61,6 +61,7 @@ export interface LocaleMessages {
61
61
  authenticatedSuccessfully: string;
62
62
  authenticationFailed: string;
63
63
  logoutSuccess: string;
64
+ userOnlyRedirected: string;
64
65
  statusTitle: string;
65
66
  notConfigured: string;
66
67
  notConfiguredInk: string;
@@ -161,6 +162,7 @@ export interface LocaleMessages {
161
162
  invalidFormat: string;
162
163
  invalidTimeout: string;
163
164
  invalidBoolean: string;
165
+ userOnlyScopeWarning: string;
164
166
  keyNotSet: string;
165
167
  configTitle: string;
166
168
  resetSuccess: string;
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { colors } from "../theme.js";
4
4
  import { t as tr } from "../../i18n/index.js";
5
+ import { sanitizeForDisplay } from "../../ui/width.js";
5
6
  export function DetailView({ data, title }) {
6
7
  const entries = Object.entries(data);
7
8
  const maxKeyLen = Math.max(...entries.map(([k]) => k.length), 0);
@@ -10,13 +11,14 @@ export function DetailView({ data, title }) {
10
11
  const dots = dotLeaderLen - key.length;
11
12
  const leader = dots > 2 ? " " + "\u00B7".repeat(dots - 1) + " " : " ";
12
13
  const isNil = value === null || value === undefined;
13
- return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: colors.text, children: key }), _jsx(Text, { color: colors.border, children: leader }), isNil ? (_jsx(Text, { dimColor: true, children: tr("common.none") })) : (_jsx(Text, { color: colors.text, children: formatValue(value) }))] }, key));
14
+ return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: colors.text, children: sanitizeForDisplay(key) }), _jsx(Text, { color: colors.border, children: leader }), isNil ? (_jsx(Text, { dimColor: true, children: tr("common.none") })) : (_jsx(Text, { color: colors.text, children: formatValue(value) }))] }, key));
14
15
  })] }));
15
16
  }
16
17
  function formatValue(value) {
17
18
  if (value === null || value === undefined)
18
19
  return "(none)";
19
- if (typeof value === "object")
20
- return JSON.stringify(value);
21
- return String(value);
20
+ const s = typeof value === "object" ? JSON.stringify(value) : String(value);
21
+ // Server-derived values may carry terminal escapes (title rewrite, screen
22
+ // clear); neutralize before rendering to the interactive TTY.
23
+ return sanitizeForDisplay(s);
22
24
  }
@@ -2,6 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { colors, icons } from "../theme.js";
4
4
  import { t as tr } from "../../i18n/index.js";
5
+ import { sanitizeForDisplay } from "../../ui/width.js";
5
6
  export function ErrorView({ message }) {
6
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, borderStyle: "round", borderColor: colors.error, paddingX: 2, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { inverse: true, color: colors.error, bold: true, children: [" ", icons.error, " ", tr("errors.label"), " "] }) }), _jsx(Box, { marginLeft: 1, children: _jsx(Text, { color: colors.error, children: message }) })] }));
7
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, borderStyle: "round", borderColor: colors.error, paddingX: 2, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { inverse: true, color: colors.error, bold: true, children: [" ", icons.error, " ", tr("errors.label"), " "] }) }), _jsx(Box, { marginLeft: 1, children: _jsx(Text, { color: colors.error, children: sanitizeForDisplay(message) }) })] }));
7
8
  }
@@ -3,7 +3,7 @@ import { Box, Text, useInput } from "ink";
3
3
  import { Fragment, useMemo, useState } from "react";
4
4
  import { boxChars, colors, icons } from "../theme.js";
5
5
  import { t as tr } from "../../i18n/index.js";
6
- import { displayWidth, truncateToWidth } from "../../ui/width.js";
6
+ import { displayWidth, truncateToWidth, sanitizeForDisplay } from "../../ui/width.js";
7
7
  import { decodeRawNav } from "../keymap.js";
8
8
  /** 1 ページあたりの行数 (ActionBar のページ送りヒント表示判定にも使う) */
9
9
  export const TABLE_PAGE_SIZE = 20;
@@ -33,10 +33,10 @@ export function TableView({ data, columns }) {
33
33
  // Column widths by visual width (CJK-aware), cap to avoid overflow
34
34
  const widths = useMemo(() => cols.map((col) => {
35
35
  const maxData = data.reduce((max, row) => {
36
- const w = displayWidth(String(row[col] ?? ""));
36
+ const w = displayWidth(sanitizeForDisplay(String(row[col] ?? "")));
37
37
  return w > max ? w : max;
38
38
  }, 0);
39
- const headerW = displayWidth(col);
39
+ const headerW = displayWidth(sanitizeForDisplay(col));
40
40
  const natural = Math.min(Math.max(headerW, maxData) + 2, MAX_COL_WIDTH);
41
41
  return Math.max(natural, MIN_COL_WIDTHS[col] ?? 0);
42
42
  }), [cols, data]);
@@ -45,13 +45,13 @@ export function TableView({ data, columns }) {
45
45
  const v = boxChars.vertical;
46
46
  const sep = boxChars.leftMid +
47
47
  widths.map((w, i) => boxChars.horizontal.repeat(w) + (i < widths.length - 1 ? boxChars.midMid : boxChars.rightMid)).join("");
48
- const headerCells = cols.map((col, i) => padToVisualWidth(" " + col, widths[i]));
48
+ const headerCells = cols.map((col, i) => padToVisualWidth(" " + sanitizeForDisplay(col), widths[i]));
49
49
  const lines = [];
50
50
  lines.push(v + headerCells.join(v) + v);
51
51
  lines.push(sep);
52
52
  for (const row of pageData) {
53
53
  const cells = cols.map((col, i) => {
54
- const raw = truncateToWidth(String(row[col] ?? "─"), widths[i] - 2);
54
+ const raw = truncateToWidth(sanitizeForDisplay(String(row[col] ?? "─")), widths[i] - 2);
55
55
  return padToVisualWidth(" " + raw + " ", widths[i]);
56
56
  });
57
57
  lines.push(v + cells.join(v) + v);