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.
- package/dist/commands/auth.js +27 -10
- package/dist/commands/config.js +6 -1
- package/dist/commands/plugin.js +20 -7
- package/dist/config/client.d.ts +53 -0
- package/dist/config/client.js +116 -1
- package/dist/config/scoped-store.d.ts +14 -0
- package/dist/config/scoped-store.js +65 -1
- package/dist/config/store.d.ts +7 -0
- package/dist/config/store.js +16 -1
- package/dist/core/logger.js +6 -1
- package/dist/i18n/locales/en.js +6 -1
- package/dist/i18n/locales/ja.js +6 -1
- package/dist/i18n/types.d.ts +2 -0
- package/dist/ink/components/DetailView.js +6 -4
- package/dist/ink/components/ErrorView.js +2 -1
- package/dist/ink/components/TableView.js +5 -5
- package/dist/ink/registry/executors.js +12 -7
- package/dist/ink/screens/AuthLoginScreen.js +19 -9
- package/dist/ink/screens/CommandScreen.js +3 -0
- package/dist/plugins/describe.d.ts +35 -0
- package/dist/plugins/describe.js +111 -6
- package/dist/plugins/dispatch.js +38 -3
- package/dist/plugins/templates.js +10 -4
- package/dist/plugins/verify.d.ts +7 -0
- package/dist/plugins/verify.js +79 -2
- package/dist/sdk/core/http-client.d.ts +11 -0
- package/dist/sdk/core/http-client.js +97 -19
- package/dist/ui/width.d.ts +11 -0
- package/dist/ui/width.js +15 -0
- package/package.json +3 -3
package/dist/commands/auth.js
CHANGED
|
@@ -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,
|
|
10
|
-
import { getUserConfigPath, getProjectConfigPath, getLocalConfigPath,
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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) =>
|
|
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
|
-
|
|
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) {
|
package/dist/commands/config.js
CHANGED
|
@@ -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;
|
package/dist/commands/plugin.js
CHANGED
|
@@ -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 (
|
|
95
|
-
row.params
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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,
|
package/dist/config/client.d.ts
CHANGED
|
@@ -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.
|
package/dist/config/client.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/config/store.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/store.js
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/dist/core/logger.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/dist/i18n/locales/en.js
CHANGED
|
@@ -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}]",
|
package/dist/i18n/locales/ja.js
CHANGED
|
@@ -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}]",
|
package/dist/i18n/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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);
|