@bluelamp/secret-exec 0.3.0 → 0.4.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/README.md +16 -2
- package/bin/bluelamp-secret.mjs +18 -2
- package/dist/clear-clipboard.d.ts +1 -0
- package/dist/clear-clipboard.js +22 -0
- package/dist/clipboard.d.ts +20 -0
- package/dist/clipboard.js +133 -0
- package/dist/copy.d.ts +96 -0
- package/dist/copy.js +207 -0
- package/dist/handoff.d.ts +51 -0
- package/dist/index.d.ts +157 -0
- package/dist/index.js +94 -4
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
> 🔒 **これは値を返さない口です。**
|
|
4
4
|
> 取れるのは「金庫にその名前が**在るか無いか**」と「値を**使えるか**(子プロセスが値を環境変数で受けて走れたか=子の終了コード)」だけです。
|
|
5
5
|
> 値を標準出力・標準エラー・戻り値・ファイル・コマンドの引数に出す口は、このパッケージには**ありません**。今後も作りません。
|
|
6
|
-
>
|
|
6
|
+
> 値を見たい用途(表示・ファイルへの書き出し)には使えません。それは設計上の意図です。
|
|
7
|
+
> 例外は 1 つだけ: `copy` は、**持ち主の画面に出る確認の窓で「コピー」を押したときだけ**値をクリップボードへ入れます(下の「クリップボードへ入れる」)。値は出力には出ません。
|
|
7
8
|
|
|
8
9
|
金庫(password-manager)に置いた値を、**子プロセスの環境変数にだけ**入れてコマンドを実行します(`exec`)。終了コードは子のものをそのまま返します。
|
|
9
|
-
値を会話に出さずに金庫へ**入れる**口(`put
|
|
10
|
+
値を会話に出さずに金庫へ**入れる**口(`put`)と、持ち主の確認を挟んでクリップボードへ入れる口(`copy`)も同梱しています。
|
|
10
11
|
|
|
11
12
|
## 使い方
|
|
12
13
|
|
|
@@ -77,3 +78,16 @@ bluelamp-secret put my-db --file ./db-url.txt --url https://db.example.com
|
|
|
77
78
|
- `resolveHandoffBins(overrideEnvNames)` … node と bluelamp-secret の実体の絶対パス(launchd の貧しい PATH に依らない)。見つからなければ null
|
|
78
79
|
|
|
79
80
|
⛔ 値を返す口は無い(テスト P10 が export の一覧を縛る)。外側の `env -u CLAUDE_CODE_OAUTH_TOKEN`(親の継承を切る)は呼び手が付ける。
|
|
81
|
+
|
|
82
|
+
## クリップボードへ入れる(copy)
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
bluelamp-secret copy "<金庫の名前>"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- 持ち主の画面に OS の確認の窓(「<名前> のパスワードをクリップボードにコピーしますか?」コピー/やめる)を出し、**「コピー」を押したときだけ**値をクリップボードへ入れます。45 秒たってクリップボードがまだその値なら空にします(別の物をコピーし直していれば触りません)。
|
|
89
|
+
- 決めるのは毎回、画面の前の持ち主です。エージェント(Claude Code など)から撃っても、窓を押さなければ入りません。窓が 120 秒押されなければ「やめる」と同じです。
|
|
90
|
+
- 名前が完全に一致しなければ、名前か利用者名に(大文字小文字を問わず)含む候補を窓に並べて選んでもらいます(最大 20 件)。名前は値の列を持たない一覧で当てるので、「やめる」を押したときは金庫の値を引きません。
|
|
91
|
+
- Mac: 窓は `osascript`、クリップボードは `pbcopy`。Windows: PowerShell のフォームと `System.Windows.Forms.Clipboard`(クリップボードの履歴とクラウド同期には載せない印を付けます)。WSL から撃っても Windows 側の画面とクリップボードに出ます。
|
|
92
|
+
- 窓を出せない機体では**黙ってコピーせず断ります**: ssh 越し(`SSH_CONNECTION` 等)・CI・画面にログインしていない利用者の Mac・WSL でない Linux。
|
|
93
|
+
- 出力は「コピーしました」「やめました」と理由だけです。終了コード: 0 コピーした/1 失敗/2 引数の形/3 やめた/4 窓を出せない。
|
package/bin/bluelamp-secret.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* bluelamp-secret — 金庫の値を子プロセスの環境変数にだけ入れてコマンドを走らせる(箱 n=2018 設計④′)。
|
|
4
4
|
* bluelamp-secret exec <ENV名>=<金庫の名前> [...] -- <コマンド> [引数…]
|
|
5
5
|
* <値を出すコマンド> | bluelamp-secret put <金庫の名前> [--note 文] [--username 名] [--url URL] [--file パス] [--force](箱 n=2498)
|
|
6
|
+
* bluelamp-secret copy "<金庫の名前>"(箱 n=3738・持ち主の画面の確認の窓で「コピー」を押したときだけクリップボードへ)
|
|
6
7
|
* 🔒 値を返さない口: 値は stdout・stderr・戻り値・ファイルに出ない(README の冒頭・src/index.ts の頭注)。
|
|
7
8
|
* 判定と組み立ては dist/index.js(unit テスト済み)。ここは実物の網・トークン・子プロセス・シグナルを繋ぐだけ。
|
|
8
9
|
*/
|
|
@@ -10,7 +11,8 @@ import { spawn } from 'node:child_process';
|
|
|
10
11
|
import { readFile } from 'node:fs/promises';
|
|
11
12
|
import { constants as osConstants } from 'node:os';
|
|
12
13
|
import { tryReadCliToken } from '@bluelamp/cli-token';
|
|
13
|
-
import { runSecretExec, runSecretPut } from '../dist/index.js';
|
|
14
|
+
import { runSecretCopy, runSecretExec, runSecretPut } from '../dist/index.js';
|
|
15
|
+
import { clipboardBackend, consoleIsMine, isWsl, scheduleClear } from '../dist/clipboard.js';
|
|
14
16
|
|
|
15
17
|
// 端末の Ctrl-C は同じプロセスグループの子にも届く=親は受け流して子の終了を待つ。
|
|
16
18
|
// SIGTERM / SIGHUP は親だけに来うるので子へ回す。
|
|
@@ -33,7 +35,21 @@ const run =
|
|
|
33
35
|
readFile: (p) => readFile(p, 'utf8'),
|
|
34
36
|
stderr: (text) => process.stderr.write(text),
|
|
35
37
|
})
|
|
36
|
-
:
|
|
38
|
+
: argv[0] === 'copy'
|
|
39
|
+
? runSecretCopy(argv, {
|
|
40
|
+
env: process.env,
|
|
41
|
+
host: {
|
|
42
|
+
platform: process.platform,
|
|
43
|
+
isWsl: process.platform === 'linux' && isWsl(process.env),
|
|
44
|
+
consoleIsMine: process.platform === 'darwin' && consoleIsMine(),
|
|
45
|
+
},
|
|
46
|
+
loadToken: () => tryReadCliToken(),
|
|
47
|
+
fetch: (url, init) => globalThis.fetch(url, init),
|
|
48
|
+
backend: (kind) => clipboardBackend(kind),
|
|
49
|
+
scheduleClear: (kind, value, seconds) => scheduleClear(kind, value, seconds),
|
|
50
|
+
stderr: (text) => process.stderr.write(text),
|
|
51
|
+
})
|
|
52
|
+
: runExec();
|
|
37
53
|
|
|
38
54
|
function runExec() {
|
|
39
55
|
return runSecretExec(argv, {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bluelamp-secret copy` が切り離して起こす子(箱 n=3738): 標準入力で値を受け、秒数を待って、クリップボードがまだ
|
|
3
|
+
* その値なら空にする(持ち主が別の物をコピーしていたら触らない)。⛔ 何も出力しない。
|
|
4
|
+
* node clear-clipboard.js <mac|windows|wsl> <秒>
|
|
5
|
+
*/
|
|
6
|
+
import { clipboardBackend } from './clipboard.js';
|
|
7
|
+
const KINDS = ['mac', 'windows', 'wsl'];
|
|
8
|
+
async function main() {
|
|
9
|
+
const kind = process.argv[2];
|
|
10
|
+
const seconds = Number(process.argv[3]);
|
|
11
|
+
if (!KINDS.includes(kind) || !Number.isFinite(seconds) || seconds < 0)
|
|
12
|
+
return;
|
|
13
|
+
const chunks = [];
|
|
14
|
+
for await (const c of process.stdin)
|
|
15
|
+
chunks.push(c);
|
|
16
|
+
const value = Buffer.concat(chunks).toString('utf8');
|
|
17
|
+
if (value === '')
|
|
18
|
+
return;
|
|
19
|
+
await new Promise((r) => setTimeout(r, seconds * 1000));
|
|
20
|
+
await clipboardBackend(kind).clearIfSame(value);
|
|
21
|
+
}
|
|
22
|
+
main().then(() => process.exit(0), () => process.exit(1));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type DialogAnswer, type DialogKind } from './copy.js';
|
|
2
|
+
export interface ClipboardBackend {
|
|
3
|
+
/** 窓を出して答えを待つ(choices が null なら確認だけ) */
|
|
4
|
+
dialog(prompt: string, choices: readonly string[] | null): Promise<DialogAnswer>;
|
|
5
|
+
/** 値をクリップボードへ置く。置けたら true */
|
|
6
|
+
write(value: string): Promise<boolean>;
|
|
7
|
+
/** クリップボードがまだ値と同じなら空にする */
|
|
8
|
+
clearIfSame(value: string): Promise<'cleared' | 'changed' | 'failed'>;
|
|
9
|
+
}
|
|
10
|
+
/** 窓の種類に合う口を返す。WSL は Windows 側の powershell.exe を撃つ(窓とクリップボードは Windows の画面に出る) */
|
|
11
|
+
export declare function clipboardBackend(kind: DialogKind): ClipboardBackend;
|
|
12
|
+
/** WSL の中か(/proc/version に microsoft) */
|
|
13
|
+
export declare function isWsl(env: Readonly<Record<string, string | undefined>>): boolean;
|
|
14
|
+
/** Mac の画面(/dev/console)の持ち主がこのプロセスの利用者か。測れなければ false(=窓を出さない) */
|
|
15
|
+
export declare function consoleIsMine(): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* 後で消す子を切り離して起こす(親はすぐ終わる・子は秒数の後にまだ同じ値なら空にする)。値は子の標準入力で渡す。
|
|
18
|
+
* 🔑 detached=別のプロセス群。撃った席のシェルが終わっても子は残って消しに行く。
|
|
19
|
+
*/
|
|
20
|
+
export declare function scheduleClear(kind: DialogKind, value: string, seconds: number): Promise<boolean>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bluelamp-secret copy` の OS の口(箱 n=3738)— 確認の窓・クリップボード・後で消す子。判定は copy.ts(純粋関数)。
|
|
3
|
+
*
|
|
4
|
+
* 🔑 値の通り道は「index.ts → クリップボードの子(pbcopy/powershell)の標準入力」と「→ 後で消す子の標準入力」の 2 本だけ。
|
|
5
|
+
* ⛔ 値を argv・env・ファイル・子の出力に載せない(子の stderr は捨て、stdout は窓の答え・ok/cleared の語だけを読む)。
|
|
6
|
+
* 🔑 パッケージの外へは出ない(package.json の exports に無い)。
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { MAC_CHOOSE_SCRIPT, MAC_CONFIRM_SCRIPT, WINDOWS_CLEAR_SCRIPT, WINDOWS_SET_SCRIPT, parseDialogAnswer, powerShellArgs, windowsDialogScript, } from './copy.js';
|
|
12
|
+
/** pbcopy/pbpaste が文字化けしないように UTF-8 を名指す(親の LANG が空の機体がある) */
|
|
13
|
+
const MAC_ENV = { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' };
|
|
14
|
+
const WSL_POWERSHELL = '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe';
|
|
15
|
+
/**
|
|
16
|
+
* 子を起こして stdout を読む。
|
|
17
|
+
* 🔑 窓を出す子は hide=false。Windows の windowsHide は STARTUPINFO に SW_HIDE を入れ、WinForms の最初の
|
|
18
|
+
* ShowDialog がそれを受けて窓が見えないまま開く(🔬 2026-10-25 CI の Windows で実測: 窓は 120 秒開いたが上位の窓に出なかった)。
|
|
19
|
+
*/
|
|
20
|
+
function runChild(command, args, input, env, hide = true) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
let child;
|
|
23
|
+
try {
|
|
24
|
+
child = spawn(command, [...args], { stdio: [input === null ? 'ignore' : 'pipe', 'pipe', 'ignore'], env, windowsHide: hide });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
resolve({ code: 127, stdout: '' });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const chunks = [];
|
|
31
|
+
child.stdout?.on('data', (c) => chunks.push(c));
|
|
32
|
+
child.on('error', () => resolve({ code: 127, stdout: '' }));
|
|
33
|
+
child.on('close', (code) => resolve({ code: code ?? 1, stdout: Buffer.concat(chunks).toString('utf8') }));
|
|
34
|
+
if (input !== null) {
|
|
35
|
+
child.stdin?.on('error', () => { });
|
|
36
|
+
child.stdin?.end(input, 'utf8');
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function macBackend() {
|
|
41
|
+
return {
|
|
42
|
+
async dialog(prompt, choices) {
|
|
43
|
+
const args = choices === null ? ['-e', MAC_CONFIRM_SCRIPT, prompt] : ['-e', MAC_CHOOSE_SCRIPT, prompt, ...choices];
|
|
44
|
+
const r = await runChild('osascript', args, null);
|
|
45
|
+
return r.code === 0 ? parseDialogAnswer(r.stdout) : { kind: 'nogui' };
|
|
46
|
+
},
|
|
47
|
+
async write(value) {
|
|
48
|
+
return (await runChild('pbcopy', [], value, MAC_ENV)).code === 0;
|
|
49
|
+
},
|
|
50
|
+
async clearIfSame(value) {
|
|
51
|
+
const now = await runChild('pbpaste', [], null, MAC_ENV);
|
|
52
|
+
if (now.code !== 0)
|
|
53
|
+
return 'failed';
|
|
54
|
+
if (now.stdout !== value)
|
|
55
|
+
return 'changed';
|
|
56
|
+
return (await runChild('pbcopy', [], '', MAC_ENV)).code === 0 ? 'cleared' : 'failed';
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function windowsBackend(powershell) {
|
|
61
|
+
return {
|
|
62
|
+
async dialog(prompt, choices) {
|
|
63
|
+
const r = await runChild(powershell, powerShellArgs(windowsDialogScript(prompt, choices)), null, undefined, false);
|
|
64
|
+
return r.code === 0 ? parseDialogAnswer(r.stdout) : { kind: 'nogui' };
|
|
65
|
+
},
|
|
66
|
+
async write(value) {
|
|
67
|
+
const r = await runChild(powershell, powerShellArgs(WINDOWS_SET_SCRIPT), value);
|
|
68
|
+
return r.code === 0 && r.stdout.trim() === 'ok';
|
|
69
|
+
},
|
|
70
|
+
async clearIfSame(value) {
|
|
71
|
+
const r = await runChild(powershell, powerShellArgs(WINDOWS_CLEAR_SCRIPT), value);
|
|
72
|
+
const s = r.stdout.trim();
|
|
73
|
+
if (r.code !== 0)
|
|
74
|
+
return 'failed';
|
|
75
|
+
return s === 'cleared' || s === 'changed' ? s : 'failed';
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** 窓の種類に合う口を返す。WSL は Windows 側の powershell.exe を撃つ(窓とクリップボードは Windows の画面に出る) */
|
|
80
|
+
export function clipboardBackend(kind) {
|
|
81
|
+
if (kind === 'mac')
|
|
82
|
+
return macBackend();
|
|
83
|
+
if (kind === 'windows')
|
|
84
|
+
return windowsBackend('powershell.exe');
|
|
85
|
+
return windowsBackend(fs.existsSync(WSL_POWERSHELL) ? WSL_POWERSHELL : 'powershell.exe');
|
|
86
|
+
}
|
|
87
|
+
/** WSL の中か(/proc/version に microsoft) */
|
|
88
|
+
export function isWsl(env) {
|
|
89
|
+
if (env.WSL_DISTRO_NAME)
|
|
90
|
+
return true;
|
|
91
|
+
try {
|
|
92
|
+
return /microsoft/i.test(fs.readFileSync('/proc/version', 'utf8'));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Mac の画面(/dev/console)の持ち主がこのプロセスの利用者か。測れなければ false(=窓を出さない) */
|
|
99
|
+
export function consoleIsMine() {
|
|
100
|
+
try {
|
|
101
|
+
return typeof process.getuid === 'function' && fs.statSync('/dev/console').uid === process.getuid();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 後で消す子を切り離して起こす(親はすぐ終わる・子は秒数の後にまだ同じ値なら空にする)。値は子の標準入力で渡す。
|
|
109
|
+
* 🔑 detached=別のプロセス群。撃った席のシェルが終わっても子は残って消しに行く。
|
|
110
|
+
*/
|
|
111
|
+
export function scheduleClear(kind, value, seconds) {
|
|
112
|
+
const script = fileURLToPath(new URL('./clear-clipboard.js', import.meta.url));
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
let child;
|
|
115
|
+
try {
|
|
116
|
+
child = spawn(process.execPath, [script, kind, String(seconds)], {
|
|
117
|
+
detached: true,
|
|
118
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
119
|
+
windowsHide: true,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
resolve(false);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
child.on('error', () => resolve(false));
|
|
127
|
+
child.stdin?.on('error', () => resolve(false));
|
|
128
|
+
child.stdin?.end(value, 'utf8', () => {
|
|
129
|
+
child.unref();
|
|
130
|
+
resolve(true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
package/dist/copy.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bluelamp-secret copy` の判定と組み立て(箱 n=3738)— 純粋関数だけ。網・子プロセスは clipboard.ts と index.ts の deps。
|
|
3
|
+
*
|
|
4
|
+
* 🔑 決めるのは毎回、持ち主の画面に出る OS の確認の窓の「コピー」。席(Claude Code)が撃っても、窓を押さなければ入らない。
|
|
5
|
+
* ⇒ 窓を出せない機体(ssh 越し・CI・GUI の無い Linux・他人の画面)では黙ってコピーせず断る。
|
|
6
|
+
* ⛔ ここに値は来ない(窓に出すのは金庫の名前だけ・値は index.ts の中でクリップボードの子の標準入力へ渡すだけ)。
|
|
7
|
+
*/
|
|
8
|
+
/** クリップボードに置いておく秒数(過ぎてまだその値なら空にする) */
|
|
9
|
+
export declare const CLEAR_AFTER_SECONDS = 45;
|
|
10
|
+
/** 名前の当てで窓に並べる最大の件数(多すぎる時は絞ってもらう) */
|
|
11
|
+
export declare const MAX_CHOICES = 20;
|
|
12
|
+
/** 確認の窓を開けておく秒数(押されなければ「やめる」と同じ) */
|
|
13
|
+
export declare const DIALOG_TIMEOUT_SECONDS = 120;
|
|
14
|
+
export type CopyParseResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
name: string;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
help: boolean;
|
|
20
|
+
error: string;
|
|
21
|
+
};
|
|
22
|
+
/** `copy <金庫の名前>` を解析する(`process.argv.slice(2)` を受ける)。⛔ 失敗の文言に引数の中身を引用しない */
|
|
23
|
+
export declare function parseCopyArgs(argv: readonly string[]): CopyParseResult;
|
|
24
|
+
/** 一覧の 1 件(値の列は無い) */
|
|
25
|
+
export interface VaultListEntry {
|
|
26
|
+
service: string;
|
|
27
|
+
username?: string | null;
|
|
28
|
+
}
|
|
29
|
+
/** 一覧の応答から名前と利用者名だけを取り出す。形が違えば null(⛔ note など他の列は使わない) */
|
|
30
|
+
export declare function readVaultList(body: unknown): VaultListEntry[] | null;
|
|
31
|
+
export type NameMatch = {
|
|
32
|
+
kind: 'exact';
|
|
33
|
+
name: string;
|
|
34
|
+
} | {
|
|
35
|
+
kind: 'candidates';
|
|
36
|
+
names: string[];
|
|
37
|
+
total: number;
|
|
38
|
+
} | {
|
|
39
|
+
kind: 'none';
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* 名前を当てる。完全一致が在ればそれ。無ければ名前か利用者名に(大文字小文字を問わず)含む物を候補にする。
|
|
43
|
+
* 例: 「ishii」→「Google 役割アカウント(窓口の箱・ishii.s)」。同じ名前(自分の分と共有されてきた分)は 1 つに畳む。
|
|
44
|
+
*/
|
|
45
|
+
export declare function matchVaultName(entries: readonly VaultListEntry[], query: string): NameMatch;
|
|
46
|
+
export type DialogKind = 'mac' | 'windows' | 'wsl';
|
|
47
|
+
export interface HostInfo {
|
|
48
|
+
platform: string;
|
|
49
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
50
|
+
/** Linux のとき WSL の中か */
|
|
51
|
+
isWsl: boolean;
|
|
52
|
+
/** Mac のとき、画面(/dev/console)の持ち主がこのプロセスの利用者か。測れなければ false */
|
|
53
|
+
consoleIsMine: boolean;
|
|
54
|
+
}
|
|
55
|
+
export type DialogSupport = {
|
|
56
|
+
ok: true;
|
|
57
|
+
kind: DialogKind;
|
|
58
|
+
} | {
|
|
59
|
+
ok: false;
|
|
60
|
+
reason: string;
|
|
61
|
+
};
|
|
62
|
+
/** 確認の窓を持ち主の画面に出せるか(⛔ 出せない時は黙ってコピーしない) */
|
|
63
|
+
export declare function detectDialogSupport(host: HostInfo): DialogSupport;
|
|
64
|
+
/** 確認の窓に出す文 */
|
|
65
|
+
export declare function confirmPrompt(name: string): string;
|
|
66
|
+
/** 候補を選ぶ窓に出す文 */
|
|
67
|
+
export declare function choosePrompt(query: string, shown: number, total: number): string;
|
|
68
|
+
/** 確認の窓(argv: 文)。stdout に copy/cancel/nogui:<番号> */
|
|
69
|
+
export declare const MAC_CONFIRM_SCRIPT: string;
|
|
70
|
+
/** 候補を選ぶ窓(argv: 文, 候補…)。stdout に pick:<番号(0 から)>/cancel/nogui:<番号> */
|
|
71
|
+
export declare const MAC_CHOOSE_SCRIPT: string;
|
|
72
|
+
/** `-EncodedCommand` に渡す形(UTF-16LE の base64) */
|
|
73
|
+
export declare function encodePowerShell(script: string): string;
|
|
74
|
+
/** powershell.exe に渡す引数(スクリプトは encoded で渡す=値はここに来ない) */
|
|
75
|
+
export declare function powerShellArgs(script: string): string[];
|
|
76
|
+
/**
|
|
77
|
+
* 窓(確認か候補選び)を出す PowerShell。stdout に copy/pick:<番号>/cancel/nogui。
|
|
78
|
+
* 🔑 session 0(サービス)や非対話では窓が誰にも見えない=nogui。MessageBox はボタンの名前を変えられないので小さなフォームで出す。
|
|
79
|
+
*/
|
|
80
|
+
export declare function windowsDialogScript(prompt: string, choices: readonly string[] | null): string;
|
|
81
|
+
/** 標準入力の値をクリップボードへ置く PowerShell(クリップボードの履歴・クラウド同期には載せない印を付ける) */
|
|
82
|
+
export declare const WINDOWS_SET_SCRIPT: string;
|
|
83
|
+
/** 標準入力の値とクリップボードが同じなら空にする PowerShell。stdout に cleared/changed */
|
|
84
|
+
export declare const WINDOWS_CLEAR_SCRIPT: string;
|
|
85
|
+
export type DialogAnswer = {
|
|
86
|
+
kind: 'copy';
|
|
87
|
+
} | {
|
|
88
|
+
kind: 'pick';
|
|
89
|
+
index: number;
|
|
90
|
+
} | {
|
|
91
|
+
kind: 'cancel';
|
|
92
|
+
} | {
|
|
93
|
+
kind: 'nogui';
|
|
94
|
+
};
|
|
95
|
+
/** 窓の子の stdout を答えにする(知らない形は nogui=コピーしない側へ倒す) */
|
|
96
|
+
export declare function parseDialogAnswer(stdout: string): DialogAnswer;
|
package/dist/copy.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bluelamp-secret copy` の判定と組み立て(箱 n=3738)— 純粋関数だけ。網・子プロセスは clipboard.ts と index.ts の deps。
|
|
3
|
+
*
|
|
4
|
+
* 🔑 決めるのは毎回、持ち主の画面に出る OS の確認の窓の「コピー」。席(Claude Code)が撃っても、窓を押さなければ入らない。
|
|
5
|
+
* ⇒ 窓を出せない機体(ssh 越し・CI・GUI の無い Linux・他人の画面)では黙ってコピーせず断る。
|
|
6
|
+
* ⛔ ここに値は来ない(窓に出すのは金庫の名前だけ・値は index.ts の中でクリップボードの子の標準入力へ渡すだけ)。
|
|
7
|
+
*/
|
|
8
|
+
/** クリップボードに置いておく秒数(過ぎてまだその値なら空にする) */
|
|
9
|
+
export const CLEAR_AFTER_SECONDS = 45;
|
|
10
|
+
/** 名前の当てで窓に並べる最大の件数(多すぎる時は絞ってもらう) */
|
|
11
|
+
export const MAX_CHOICES = 20;
|
|
12
|
+
/** 確認の窓を開けておく秒数(押されなければ「やめる」と同じ) */
|
|
13
|
+
export const DIALOG_TIMEOUT_SECONDS = 120;
|
|
14
|
+
/** `copy <金庫の名前>` を解析する(`process.argv.slice(2)` を受ける)。⛔ 失敗の文言に引数の中身を引用しない */
|
|
15
|
+
export function parseCopyArgs(argv) {
|
|
16
|
+
if (argv[0] !== 'copy')
|
|
17
|
+
return { ok: false, help: false, error: '不明な操作です(受けるのは exec と put と copy だけ)' };
|
|
18
|
+
const rest = argv.slice(1);
|
|
19
|
+
if (rest.length === 0 || rest.includes('-h') || rest.includes('--help'))
|
|
20
|
+
return { ok: false, help: true, error: '' };
|
|
21
|
+
if (rest.some((a) => a.startsWith('-')))
|
|
22
|
+
return { ok: false, help: false, error: 'copy はオプションを受けません' };
|
|
23
|
+
if (rest.length !== 1)
|
|
24
|
+
return { ok: false, help: false, error: '金庫の名前は 1 つだけ書いてください(空白を含むなら "" で囲みます)' };
|
|
25
|
+
const name = rest[0];
|
|
26
|
+
if (name === '' || name === '.' || name === '..')
|
|
27
|
+
return { ok: false, help: false, error: '金庫の名前が使えない形です' };
|
|
28
|
+
return { ok: true, name };
|
|
29
|
+
}
|
|
30
|
+
/** 一覧の応答から名前と利用者名だけを取り出す。形が違えば null(⛔ note など他の列は使わない) */
|
|
31
|
+
export function readVaultList(body) {
|
|
32
|
+
if (!body || typeof body !== 'object')
|
|
33
|
+
return null;
|
|
34
|
+
const b = body;
|
|
35
|
+
if (b.success !== true || !Array.isArray(b.data))
|
|
36
|
+
return null;
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const e of b.data) {
|
|
39
|
+
if (!e || typeof e !== 'object')
|
|
40
|
+
continue;
|
|
41
|
+
const { service, username } = e;
|
|
42
|
+
if (typeof service !== 'string')
|
|
43
|
+
continue;
|
|
44
|
+
out.push({ service, username: typeof username === 'string' ? username : null });
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 名前を当てる。完全一致が在ればそれ。無ければ名前か利用者名に(大文字小文字を問わず)含む物を候補にする。
|
|
50
|
+
* 例: 「ishii」→「Google 役割アカウント(窓口の箱・ishii.s)」。同じ名前(自分の分と共有されてきた分)は 1 つに畳む。
|
|
51
|
+
*/
|
|
52
|
+
export function matchVaultName(entries, query) {
|
|
53
|
+
if (entries.some((e) => e.service === query))
|
|
54
|
+
return { kind: 'exact', name: query };
|
|
55
|
+
const q = query.toLowerCase();
|
|
56
|
+
const names = [];
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
const hit = e.service.toLowerCase().includes(q) || (e.username ?? '').toLowerCase().includes(q);
|
|
59
|
+
if (hit && !names.includes(e.service))
|
|
60
|
+
names.push(e.service);
|
|
61
|
+
}
|
|
62
|
+
if (names.length === 0)
|
|
63
|
+
return { kind: 'none' };
|
|
64
|
+
return { kind: 'candidates', names: names.slice(0, MAX_CHOICES), total: names.length };
|
|
65
|
+
}
|
|
66
|
+
/** 確認の窓を持ち主の画面に出せるか(⛔ 出せない時は黙ってコピーしない) */
|
|
67
|
+
export function detectDialogSupport(host) {
|
|
68
|
+
// 🔑 ssh 越しは窓が向こうの機体の画面に出る(撃った人の画面ではない)。CI は見ている人がいない。
|
|
69
|
+
if (host.env.SSH_CONNECTION || host.env.SSH_CLIENT || host.env.SSH_TTY) {
|
|
70
|
+
return { ok: false, reason: 'ssh 越しです' };
|
|
71
|
+
}
|
|
72
|
+
if (host.env.CI)
|
|
73
|
+
return { ok: false, reason: 'CI の中です' };
|
|
74
|
+
if (host.platform === 'darwin') {
|
|
75
|
+
return host.consoleIsMine ? { ok: true, kind: 'mac' } : { ok: false, reason: 'この利用者は画面にログインしていません' };
|
|
76
|
+
}
|
|
77
|
+
if (host.platform === 'win32')
|
|
78
|
+
return { ok: true, kind: 'windows' };
|
|
79
|
+
if (host.platform === 'linux' && host.isWsl)
|
|
80
|
+
return { ok: true, kind: 'wsl' };
|
|
81
|
+
return { ok: false, reason: 'この OS では確認の窓に対応していません' };
|
|
82
|
+
}
|
|
83
|
+
/** 確認の窓に出す文 */
|
|
84
|
+
export function confirmPrompt(name) {
|
|
85
|
+
return `「${name}」のパスワードをクリップボードにコピーしますか?\n${CLEAR_AFTER_SECONDS} 秒たつとクリップボードから消えます。`;
|
|
86
|
+
}
|
|
87
|
+
/** 候補を選ぶ窓に出す文 */
|
|
88
|
+
export function choosePrompt(query, shown, total) {
|
|
89
|
+
const more = total > shown ? `(${total} 件のうち ${shown} 件を出しています。名前を長く書くと絞れます)` : '';
|
|
90
|
+
return `「${query}」に当たる名前がいくつかあります。コピーするパスワードを選んでください。${more}\n${CLEAR_AFTER_SECONDS} 秒たつとクリップボードから消えます。`;
|
|
91
|
+
}
|
|
92
|
+
// ── Mac(osascript)。名前・文は argv で渡す(スクリプトに埋め込まない=引用の崩れが無い) ──────────
|
|
93
|
+
/** 確認の窓(argv: 文)。stdout に copy/cancel/nogui:<番号> */
|
|
94
|
+
export const MAC_CONFIRM_SCRIPT = [
|
|
95
|
+
'on run argv',
|
|
96
|
+
'try',
|
|
97
|
+
'tell me to activate',
|
|
98
|
+
`set r to display dialog (item 1 of argv) with title "BlueLamp 金庫" buttons {"やめる", "コピー"} default button "コピー" cancel button "やめる" with icon caution giving up after ${DIALOG_TIMEOUT_SECONDS}`,
|
|
99
|
+
'on error number n',
|
|
100
|
+
'if n is -128 then return "cancel"',
|
|
101
|
+
'return "nogui:" & n',
|
|
102
|
+
'end try',
|
|
103
|
+
'if gave up of r then return "cancel"',
|
|
104
|
+
'return "copy"',
|
|
105
|
+
'end run',
|
|
106
|
+
].join('\n');
|
|
107
|
+
/** 候補を選ぶ窓(argv: 文, 候補…)。stdout に pick:<番号(0 から)>/cancel/nogui:<番号> */
|
|
108
|
+
export const MAC_CHOOSE_SCRIPT = [
|
|
109
|
+
'on run argv',
|
|
110
|
+
'set p to item 1 of argv',
|
|
111
|
+
'set c to rest of argv',
|
|
112
|
+
'try',
|
|
113
|
+
'tell me to activate',
|
|
114
|
+
'set r to choose from list c with title "BlueLamp 金庫" with prompt p default items {item 1 of c} OK button name "コピー" cancel button name "やめる"',
|
|
115
|
+
'on error number n',
|
|
116
|
+
'return "nogui:" & n',
|
|
117
|
+
'end try',
|
|
118
|
+
'if r is false then return "cancel"',
|
|
119
|
+
'set picked to item 1 of r',
|
|
120
|
+
'repeat with i from 1 to count of c',
|
|
121
|
+
'if item i of c is picked then return "pick:" & (i - 1)',
|
|
122
|
+
'end repeat',
|
|
123
|
+
'return "cancel"',
|
|
124
|
+
'end run',
|
|
125
|
+
].join('\n');
|
|
126
|
+
// ── Windows(PowerShell)。文と候補は base64 の JSON で埋める・値は標準入力でだけ渡す ──────────
|
|
127
|
+
/** `-EncodedCommand` に渡す形(UTF-16LE の base64) */
|
|
128
|
+
export function encodePowerShell(script) {
|
|
129
|
+
return Buffer.from(script, 'utf16le').toString('base64');
|
|
130
|
+
}
|
|
131
|
+
/** powershell.exe に渡す引数(スクリプトは encoded で渡す=値はここに来ない) */
|
|
132
|
+
export function powerShellArgs(script) {
|
|
133
|
+
return ['-NoProfile', '-NonInteractive', '-Sta', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodePowerShell(script)];
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* 窓(確認か候補選び)を出す PowerShell。stdout に copy/pick:<番号>/cancel/nogui。
|
|
137
|
+
* 🔑 session 0(サービス)や非対話では窓が誰にも見えない=nogui。MessageBox はボタンの名前を変えられないので小さなフォームで出す。
|
|
138
|
+
*/
|
|
139
|
+
export function windowsDialogScript(prompt, choices) {
|
|
140
|
+
const data = Buffer.from(JSON.stringify({ prompt, choices: choices ?? [] }), 'utf8').toString('base64');
|
|
141
|
+
return [
|
|
142
|
+
"$ErrorActionPreference = 'Stop'",
|
|
143
|
+
'try {',
|
|
144
|
+
" if ((Get-Process -Id $PID).SessionId -eq 0 -or -not [Environment]::UserInteractive) { 'nogui'; exit 0 }",
|
|
145
|
+
' Add-Type -AssemblyName System.Windows.Forms',
|
|
146
|
+
' Add-Type -AssemblyName System.Drawing',
|
|
147
|
+
` $d = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${data}')) | ConvertFrom-Json`,
|
|
148
|
+
' $f = New-Object Windows.Forms.Form',
|
|
149
|
+
" $f.Text = 'BlueLamp 金庫'; $f.TopMost = $true; $f.StartPosition = 'CenterScreen'",
|
|
150
|
+
" $f.FormBorderStyle = 'FixedDialog'; $f.MinimizeBox = $false; $f.MaximizeBox = $false",
|
|
151
|
+
" $f.AutoSize = $true; $f.AutoSizeMode = 'GrowAndShrink'; $f.Padding = New-Object Windows.Forms.Padding 12",
|
|
152
|
+
" $p = New-Object Windows.Forms.FlowLayoutPanel; $p.FlowDirection = 'TopDown'; $p.AutoSize = $true; $p.AutoSizeMode = 'GrowAndShrink'",
|
|
153
|
+
' $l = New-Object Windows.Forms.Label; $l.Text = $d.prompt; $l.AutoSize = $true; $l.MaximumSize = New-Object Drawing.Size 480, 0',
|
|
154
|
+
' $p.Controls.Add($l)',
|
|
155
|
+
' $lb = $null',
|
|
156
|
+
' if (@($d.choices).Count -gt 0) {',
|
|
157
|
+
' $lb = New-Object Windows.Forms.ListBox; $lb.Width = 460; $lb.Height = [Math]::Min(@($d.choices).Count, 12) * 20 + 8',
|
|
158
|
+
' foreach ($c in $d.choices) { [void]$lb.Items.Add([string]$c) }',
|
|
159
|
+
' $lb.SelectedIndex = 0; $p.Controls.Add($lb)',
|
|
160
|
+
' }',
|
|
161
|
+
" $bp = New-Object Windows.Forms.FlowLayoutPanel; $bp.FlowDirection = 'RightToLeft'; $bp.AutoSize = $true; $bp.Width = 460",
|
|
162
|
+
" $ok = New-Object Windows.Forms.Button; $ok.Text = 'コピー'; $ok.Name = 'copy'; $ok.DialogResult = 'OK'; $ok.AutoSize = $true",
|
|
163
|
+
" $ng = New-Object Windows.Forms.Button; $ng.Text = 'やめる'; $ng.Name = 'cancel'; $ng.DialogResult = 'Cancel'; $ng.AutoSize = $true",
|
|
164
|
+
' $bp.Controls.Add($ng); $bp.Controls.Add($ok); $p.Controls.Add($bp)',
|
|
165
|
+
' $f.AcceptButton = $ok; $f.CancelButton = $ng; $f.Controls.Add($p)',
|
|
166
|
+
` $t = New-Object Windows.Forms.Timer; $t.Interval = ${DIALOG_TIMEOUT_SECONDS * 1000}`,
|
|
167
|
+
" $t.Add_Tick({ $t.Stop(); $f.DialogResult = 'Cancel' }); $t.Start()",
|
|
168
|
+
' $f.Add_Shown({ $f.Activate() })',
|
|
169
|
+
' $r = $f.ShowDialog()',
|
|
170
|
+
" if ($r -eq 'OK') { if ($lb) { 'pick:' + $lb.SelectedIndex } else { 'copy' } } else { 'cancel' }",
|
|
171
|
+
"} catch { 'nogui' }",
|
|
172
|
+
].join('\n');
|
|
173
|
+
}
|
|
174
|
+
/** 標準入力の値をクリップボードへ置く PowerShell(クリップボードの履歴・クラウド同期には載せない印を付ける) */
|
|
175
|
+
export const WINDOWS_SET_SCRIPT = [
|
|
176
|
+
"$ErrorActionPreference = 'Stop'",
|
|
177
|
+
'[Console]::InputEncoding = New-Object Text.UTF8Encoding $false',
|
|
178
|
+
'$v = [Console]::In.ReadToEnd()',
|
|
179
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
180
|
+
'$o = New-Object Windows.Forms.DataObject',
|
|
181
|
+
'$o.SetData([Windows.Forms.DataFormats]::UnicodeText, $v)',
|
|
182
|
+
"foreach ($k in 'ExcludeClipboardContentFromMonitorProcessing', 'CanIncludeInClipboardHistory', 'CanUploadToCloudClipboard') {",
|
|
183
|
+
' $o.SetData($k, (New-Object IO.MemoryStream (, [byte[]](0, 0, 0, 0))))',
|
|
184
|
+
'}',
|
|
185
|
+
'[Windows.Forms.Clipboard]::SetDataObject($o, $true)',
|
|
186
|
+
"'ok'",
|
|
187
|
+
].join('\n');
|
|
188
|
+
/** 標準入力の値とクリップボードが同じなら空にする PowerShell。stdout に cleared/changed */
|
|
189
|
+
export const WINDOWS_CLEAR_SCRIPT = [
|
|
190
|
+
"$ErrorActionPreference = 'Stop'",
|
|
191
|
+
'[Console]::InputEncoding = New-Object Text.UTF8Encoding $false',
|
|
192
|
+
'$v = [Console]::In.ReadToEnd()',
|
|
193
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
194
|
+
"if ([Windows.Forms.Clipboard]::GetText() -ceq $v) { [Windows.Forms.Clipboard]::Clear(); 'cleared' } else { 'changed' }",
|
|
195
|
+
].join('\n');
|
|
196
|
+
/** 窓の子の stdout を答えにする(知らない形は nogui=コピーしない側へ倒す) */
|
|
197
|
+
export function parseDialogAnswer(stdout) {
|
|
198
|
+
const s = stdout.trim();
|
|
199
|
+
if (s === 'copy')
|
|
200
|
+
return { kind: 'copy' };
|
|
201
|
+
if (s === 'cancel')
|
|
202
|
+
return { kind: 'cancel' };
|
|
203
|
+
const m = /^pick:(\d+)$/.exec(s);
|
|
204
|
+
if (m)
|
|
205
|
+
return { kind: 'pick', index: Number(m[1]) };
|
|
206
|
+
return { kind: 'nogui' };
|
|
207
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** 子の env に金庫の値を入れる仮の名(exec 前に unset する)。 */
|
|
2
|
+
export declare const CRED_ENV_NAME = "MO_ACCOUNT_CRED";
|
|
3
|
+
/**
|
|
4
|
+
* 金庫の値から accessToken を取り出して stdout に出す(`node -e` に渡す本文)。
|
|
5
|
+
* 🔐 本文に値は無い(値は env から読む)。stdout はコマンド置換(pipe)で受けるのでディスクに落ちない。
|
|
6
|
+
*/
|
|
7
|
+
export declare const EXTRACT_TOKEN_JS: string;
|
|
8
|
+
/** 取り出せるかだけを終了コードで返す本文(0=取り出せる・97=形が違う/空)。⛔ 何も出力しない */
|
|
9
|
+
export declare const PROBE_TOKEN_JS: string;
|
|
10
|
+
/**
|
|
11
|
+
* 値を受けて exec する sh の本文(`$1`=node・`$2`=取り出す本文・`$3`=元の command)。
|
|
12
|
+
* 値は `$(…)` で変数に受けるだけ=argv に出ない。仮の名は exec 前に unset。
|
|
13
|
+
*/
|
|
14
|
+
export declare const HANDOFF_SH: string;
|
|
15
|
+
/** 金庫の名前に許す文字(シェルの 1 語にそのまま載るので狭く取る) */
|
|
16
|
+
export declare const VAULT_NAME_RE: RegExp;
|
|
17
|
+
/** POSIX sh の 1 語にクォートする(純関数) */
|
|
18
|
+
export declare function shellQuote(s: string): string;
|
|
19
|
+
export interface HandoffBins {
|
|
20
|
+
/** node の絶対パス(launchd の貧しい PATH に依らない) */
|
|
21
|
+
node: string;
|
|
22
|
+
/** bluelamp-secret の実体(.mjs)の絶対パス。shebang の `env node` に依らないよう node で直接起こす */
|
|
23
|
+
secretExec: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* `command` を「金庫 `vaultName` の鍵を `CLAUDE_CODE_OAUTH_TOKEN` に入れてから exec する」sh の 1 行に包む(純関数)。
|
|
27
|
+
* 外側の `env -u CLAUDE_CODE_OAUTH_TOKEN`(親の継承を切る多重防御)は呼び手が付ける。
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildCredentialHandoff(command: string, vaultName: string, bins: HandoffBins): string;
|
|
30
|
+
/** 取り出せるかだけを確かめる argv(純関数・シェルを通さず spawn する)。 */
|
|
31
|
+
export declare function buildCredentialProbeArgv(vaultName: string, bins: HandoffBins): string[];
|
|
32
|
+
/** 金庫の鍵を取り出せるか(I/O)。⛔ stdout/stderr は捨てる(値は出ないが、bluelamp-secret の案内も呼び手に返さない) */
|
|
33
|
+
export declare function probeCredential(vaultName: string, bins: HandoffBins, timeoutMs?: number): {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
exitCode: number | null;
|
|
36
|
+
};
|
|
37
|
+
export declare const SECRET_EXEC_NAME = "bluelamp-secret";
|
|
38
|
+
/**
|
|
39
|
+
* bluelamp-secret を探す先(純関数・箱 n=2730)。順は
|
|
40
|
+
* 呼び手の上書き(env の値・先頭から)→ /opt/homebrew/bin → /usr/local/bin → PATH の各段 → node と同じ bin(npm の既定 prefix)。
|
|
41
|
+
* Mac の 2 か所を PATH より前に置く=launchd の貧しい PATH でも、Mac で選ばれる実体は変わらない。
|
|
42
|
+
*/
|
|
43
|
+
export declare function secretExecCandidates(env: NodeJS.ProcessEnv, execPath: string, overrideEnvNames?: readonly string[]): string[];
|
|
44
|
+
export interface HandoffBinsDeps {
|
|
45
|
+
env: NodeJS.ProcessEnv;
|
|
46
|
+
execPath: string;
|
|
47
|
+
realpath: (p: string) => string;
|
|
48
|
+
npmGlobal: () => string | null;
|
|
49
|
+
}
|
|
50
|
+
/** node / bluelamp-secret の絶対パスを決める。見つからなければ null(呼び手が自分の言葉でエラーにする)。 */
|
|
51
|
+
export declare function resolveHandoffBins(overrideEnvNames?: readonly string[], deps?: Partial<HandoffBinsDeps>): HandoffBins | null;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bluelamp/secret-exec — `bluelamp-secret exec`(箱 n=2018 設計④′)と `bluelamp-secret put`(箱 n=2498・下の put の節)と
|
|
3
|
+
* `bluelamp-secret copy`(箱 n=3738・末尾の copy の節)
|
|
4
|
+
*
|
|
5
|
+
* 🔒 これは「値を返さない口」である(実装より先に書いた線・README の冒頭と同じ)。
|
|
6
|
+
* 取れるのは「金庫にその名前が在るか無いか」と「値を使えるか(子プロセスが env で受けて走れたか=終了コード)」だけ。
|
|
7
|
+
* ⛔ 値を標準出力・標準エラー・戻り値・ファイル・argv・ログ・エラーの文言に出す口を作らない。
|
|
8
|
+
* ⛔ 値を返す関数を export しない(呼び手に値を渡す API を足したくなったら、それはこのパッケージの仕事ではない)。
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* 🕳 塞いでいる穴: MCP の `get_secret` は値を返り値にしていたため、値が会話の転写に残った。
|
|
12
|
+
* その口を閉じると、値を Bash で使っていた手順(21 日で 235 回)が壊れる。⇒ 値を「見せずに使う」口をここに置く。
|
|
13
|
+
*
|
|
14
|
+
* 🔑 値の通り道は「金庫の HTTP 応答 → 子の env」の 1 本だけ。
|
|
15
|
+
* ⛔ HTTP の本文・fetch の例外をそのまま出さない(本文は値を、例外は要求を引用しうる)。
|
|
16
|
+
* 失敗は「どの ENV 名・どの金庫の名前が・なぜ」だけを返す。
|
|
17
|
+
* 🔑 パッケージの外へ出す API は無い(package.json の exports は package.json だけ)。
|
|
18
|
+
* 下の export は同じパッケージの bin と unit テストのためのもの。値を返す関数は export しない(`extractSecretValue` は非公開)。`runSecretExec` の戻り値は終了コードだけ。
|
|
19
|
+
* 🔑 使用の記録: `GET /vault/:service` は backend 側で監査 `get`(誰が・どの秘密を)を 1 行書く。
|
|
20
|
+
*
|
|
21
|
+
* 判定と組み立ては純粋関数(unit で直接撃つ)、網と子プロセスは `runSecretExec` の deps に寄せる。
|
|
22
|
+
*/
|
|
23
|
+
import type { ClipboardBackend } from './clipboard.js';
|
|
24
|
+
import { type DialogKind, type HostInfo } from './copy.js';
|
|
25
|
+
/** ENV 名として受ける形。⛔ これ以外(小文字・記号・先頭数字)は受けない */
|
|
26
|
+
export declare const ENV_NAME_PATTERN: RegExp;
|
|
27
|
+
/** 既定のポータル API(bluelamp CLI の portal-api-client・TokenManager と同じ値) */
|
|
28
|
+
export declare const DEFAULT_PORTAL_API_BASE_URL = "https://api.motohi.jp/api/bluelamp";
|
|
29
|
+
export declare const USAGE: string;
|
|
30
|
+
export interface SecretBinding {
|
|
31
|
+
env: string;
|
|
32
|
+
name: string;
|
|
33
|
+
}
|
|
34
|
+
export type ParseResult = {
|
|
35
|
+
ok: true;
|
|
36
|
+
bindings: SecretBinding[];
|
|
37
|
+
command: string;
|
|
38
|
+
args: string[];
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
help: boolean;
|
|
42
|
+
error: string;
|
|
43
|
+
};
|
|
44
|
+
/** 引数を解析する(`process.argv.slice(2)` を受ける)。値はここに来ない=金庫の名前だけ */
|
|
45
|
+
export declare function parseExecArgs(argv: readonly string[]): ParseResult;
|
|
46
|
+
/** 基底 URL(BLUELAMP_PORTAL_API_BASE_URL > PORTAL_URL > 既定。portal-api-client.cjs と同じ順) */
|
|
47
|
+
export declare function resolvePortalApiBaseUrl(env: Readonly<Record<string, string | undefined>>): string;
|
|
48
|
+
/**
|
|
49
|
+
* 金庫の 1 件を引く URL。
|
|
50
|
+
* 🔑 vault の sub-app は内部で `/api/vault/...` に経路を切るが、bluelamp module ごと `/api/bluelamp` に
|
|
51
|
+
* 載っているので外から見える経路は `<base>/vault/...`(base は /api/bluelamp まで含む)。
|
|
52
|
+
* 🔬 実測(無認証): `/api/bluelamp/api/vault/x` → 404/`/api/bluelamp/vault/x` → 401。
|
|
53
|
+
* mcp-list/bluelamp/playwright/src/fillSecret.ts の VAULT_FILL_PATH と同じ結論。
|
|
54
|
+
*/
|
|
55
|
+
export declare function buildVaultUrl(baseUrl: string, name: string): string;
|
|
56
|
+
/** 子の env を合成する(親を書き換えない) */
|
|
57
|
+
export declare function composeChildEnv(parentEnv: Readonly<Record<string, string | undefined>>, values: Readonly<Record<string, string>>): Record<string, string>;
|
|
58
|
+
/** HTTP の状態を理由の文言にする(⛔ 本文は使わない) */
|
|
59
|
+
export declare function describeVaultStatus(status: number): string;
|
|
60
|
+
/** 子の終わり方を終了コードにする(シグナルは 128+番号=シェルと同じ) */
|
|
61
|
+
export declare function exitCodeOf(code: number | null, signal: string | null, signalNumbers: Readonly<Record<string, number>>): number;
|
|
62
|
+
interface FetchResponseLike {
|
|
63
|
+
ok: boolean;
|
|
64
|
+
status: number;
|
|
65
|
+
json(): Promise<unknown>;
|
|
66
|
+
}
|
|
67
|
+
type FetchLike = (url: string, init: {
|
|
68
|
+
method: string;
|
|
69
|
+
headers: Record<string, string>;
|
|
70
|
+
body?: string;
|
|
71
|
+
}) => Promise<FetchResponseLike>;
|
|
72
|
+
interface ChildLike {
|
|
73
|
+
on(event: 'exit', cb: (code: number | null, signal: string | null) => void): unknown;
|
|
74
|
+
on(event: 'error', cb: (err: {
|
|
75
|
+
code?: string;
|
|
76
|
+
}) => void): unknown;
|
|
77
|
+
kill(signal?: string): unknown;
|
|
78
|
+
}
|
|
79
|
+
export interface SecretExecDeps {
|
|
80
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
81
|
+
loadToken: () => string | null;
|
|
82
|
+
fetch: FetchLike;
|
|
83
|
+
spawn: (command: string, args: string[], options: {
|
|
84
|
+
stdio: 'inherit';
|
|
85
|
+
shell: false;
|
|
86
|
+
env: Record<string, string>;
|
|
87
|
+
}) => ChildLike;
|
|
88
|
+
stderr: (text: string) => void;
|
|
89
|
+
signalNumbers: Readonly<Record<string, number>>;
|
|
90
|
+
/** 親が受けたシグナルを子へ回す口(無ければ回さない)。戻り値は解除関数 */
|
|
91
|
+
onParentSignal?: (forward: (signal: string) => void) => () => void;
|
|
92
|
+
}
|
|
93
|
+
/** 実行する。戻り値は終了コード */
|
|
94
|
+
export declare function runSecretExec(argv: readonly string[], deps: SecretExecDeps): Promise<number>;
|
|
95
|
+
/**
|
|
96
|
+
* 🔒 exec と対になる「値を会話に出さない書き込みの口」。
|
|
97
|
+
* 値の通り道は「標準入力(か --file のファイル)→ 金庫への POST 本文」の 1 本だけ。
|
|
98
|
+
* ⛔ 値を引数で受けない(argv はプロセス一覧と転写に残る)。⛔ 値・HTTP の本文を出力に出さない。
|
|
99
|
+
* 🔑 金庫の POST は同じ名前を黙って上書きする(🔬 2026-10-05 本番で実測)。⇒ --force が無ければ
|
|
100
|
+
* 先に一覧(値を含まない)で「自分の金庫に同じ名前が在るか」を見て止める。共有されてきた同名は
|
|
101
|
+
* 自分の金庫には無いので止めない(書けば自分の側に別の 1 件ができ、exec は自分の側を先に引く)。
|
|
102
|
+
* 🔑 保存の後に読み戻して一致を確かめる(読み戻しは exec と同じ GET=監査に get が 1 行残る)。
|
|
103
|
+
*/
|
|
104
|
+
export interface PutOptions {
|
|
105
|
+
name: string;
|
|
106
|
+
note?: string;
|
|
107
|
+
username?: string;
|
|
108
|
+
/** --url から導いた origin(scheme+host+port)。金庫の列は bound_origins */
|
|
109
|
+
origin?: string;
|
|
110
|
+
file?: string;
|
|
111
|
+
force: boolean;
|
|
112
|
+
}
|
|
113
|
+
export type PutParseResult = {
|
|
114
|
+
ok: true;
|
|
115
|
+
options: PutOptions;
|
|
116
|
+
} | {
|
|
117
|
+
ok: false;
|
|
118
|
+
help: boolean;
|
|
119
|
+
error: string;
|
|
120
|
+
};
|
|
121
|
+
/** --url を origin に畳む(http/https だけ受ける)。畳めなければ null */
|
|
122
|
+
export declare function deriveOrigin(raw: string): string | null;
|
|
123
|
+
/**
|
|
124
|
+
* put の引数を解析する(`process.argv.slice(2)` を受ける)。
|
|
125
|
+
* ⛔ 失敗の文言に、知らない引数や余分な位置引数の中身を引用しない(値を引数に書き間違えた場合に値が出るため)。
|
|
126
|
+
*/
|
|
127
|
+
export declare function parsePutArgs(argv: readonly string[]): PutParseResult;
|
|
128
|
+
export interface SecretPutDeps {
|
|
129
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
130
|
+
loadToken: () => string | null;
|
|
131
|
+
fetch: FetchLike;
|
|
132
|
+
/** 標準入力が端末か */
|
|
133
|
+
stdinIsTTY: () => boolean;
|
|
134
|
+
/** 標準入力を最後まで読む(⛔ 失敗時の例外の文言は出さない) */
|
|
135
|
+
readStdin: () => Promise<string>;
|
|
136
|
+
readFile: (path: string) => Promise<string>;
|
|
137
|
+
stderr: (text: string) => void;
|
|
138
|
+
}
|
|
139
|
+
/** put を実行する。戻り値は終了コード(0 保存して一致/1 失敗/2 引数・入力の形/3 既に在る) */
|
|
140
|
+
export declare function runSecretPut(argv: readonly string[], deps: SecretPutDeps): Promise<number>;
|
|
141
|
+
/**
|
|
142
|
+
* 🔒 決めるのは毎回、持ち主の画面の窓。値の通り道は「金庫の GET → クリップボードの子の標準入力(と後で消す子の標準入力)」だけ。
|
|
143
|
+
* ⛔ 値を出力に出さない(出すのは「コピーしました」「やめました」と理由だけ)。⛔ 窓を出せない機体では黙ってコピーしない。
|
|
144
|
+
* 🔑 名前は一覧(値の列を持たない)で先に当てる=窓で「やめる」を押されたら金庫の値は引かない(監査の get も残らない)。
|
|
145
|
+
* 終了コード: 0 コピーした/1 失敗/2 引数の形/3 やめた/4 窓を出せない
|
|
146
|
+
*/
|
|
147
|
+
export interface SecretCopyDeps {
|
|
148
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
149
|
+
host: Omit<HostInfo, 'env'>;
|
|
150
|
+
loadToken: () => string | null;
|
|
151
|
+
fetch: FetchLike;
|
|
152
|
+
backend: (kind: DialogKind) => Pick<ClipboardBackend, 'dialog' | 'write'>;
|
|
153
|
+
scheduleClear: (kind: DialogKind, value: string, seconds: number) => Promise<boolean>;
|
|
154
|
+
stderr: (text: string) => void;
|
|
155
|
+
}
|
|
156
|
+
export declare function runSecretCopy(argv: readonly string[], deps: SecretCopyDeps): Promise<number>;
|
|
157
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @bluelamp/secret-exec — `bluelamp-secret exec`(箱 n=2018 設計④′)と `bluelamp-secret put`(箱 n=2498・下の put
|
|
2
|
+
* @bluelamp/secret-exec — `bluelamp-secret exec`(箱 n=2018 設計④′)と `bluelamp-secret put`(箱 n=2498・下の put の節)と
|
|
3
|
+
* `bluelamp-secret copy`(箱 n=3738・末尾の copy の節)
|
|
3
4
|
*
|
|
4
5
|
* 🔒 これは「値を返さない口」である(実装より先に書いた線・README の冒頭と同じ)。
|
|
5
6
|
* 取れるのは「金庫にその名前が在るか無いか」と「値を使えるか(子プロセスが env で受けて走れたか=終了コード)」だけ。
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
*
|
|
20
21
|
* 判定と組み立ては純粋関数(unit で直接撃つ)、網と子プロセスは `runSecretExec` の deps に寄せる。
|
|
21
22
|
*/
|
|
23
|
+
import { CLEAR_AFTER_SECONDS, choosePrompt, confirmPrompt, detectDialogSupport, matchVaultName, parseCopyArgs, readVaultList, } from './copy.js';
|
|
22
24
|
/** ENV 名として受ける形。⛔ これ以外(小文字・記号・先頭数字)は受けない */
|
|
23
25
|
export const ENV_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
24
26
|
/** 既定のポータル API(bluelamp CLI の portal-api-client・TokenManager と同じ値) */
|
|
@@ -28,7 +30,10 @@ export const USAGE = '使い方: bluelamp-secret exec <ENV名>=<金庫の名前>
|
|
|
28
30
|
' 値は画面・引数・ログに出ません。シェルの展開が要るときは -- sh -c \'psql "$PGURL" -c "select 1"\' のように渡します。\n' +
|
|
29
31
|
'使い方: <値を出すコマンド> | bluelamp-secret put <金庫の名前> [--note 文] [--username 名] [--url URL] [--file パス] [--force]\n' +
|
|
30
32
|
' 値を標準入力(または --file のファイル)から読んで金庫へ入れ、読み戻して一致を確かめます。末尾の改行 1 つは落とします。\n' +
|
|
31
|
-
' 既に在る名前は --force が無ければ止まります。標準入力が端末なら受けません(打ち込んだ値が画面に残るため)。\n'
|
|
33
|
+
' 既に在る名前は --force が無ければ止まります。標準入力が端末なら受けません(打ち込んだ値が画面に残るため)。\n' +
|
|
34
|
+
'使い方: bluelamp-secret copy "<金庫の名前>"\n' +
|
|
35
|
+
' 持ち主の画面に確認の窓を出し、「コピー」を押したときだけ値をクリップボードへ入れます(45 秒で消えます)。\n' +
|
|
36
|
+
' 名前が完全に一致しなければ、当たる候補を窓に並べて選んでもらいます。窓を出せない機体(ssh 越し・CI など)では断ります。\n';
|
|
32
37
|
/** `.`/`..` は URL の区間として解決され、金庫の外の経路を指してしまう(%2E も URL 側で同じに扱われる) */
|
|
33
38
|
function isUsableVaultName(name) {
|
|
34
39
|
return name !== '' && name !== '.' && name !== '..';
|
|
@@ -39,7 +44,7 @@ export function parseExecArgs(argv) {
|
|
|
39
44
|
return { ok: false, help: true, error: '' };
|
|
40
45
|
}
|
|
41
46
|
if (argv[0] !== 'exec') {
|
|
42
|
-
return { ok: false, help: false, error: `不明な操作です(受けるのは exec と put だけ)` };
|
|
47
|
+
return { ok: false, help: false, error: `不明な操作です(受けるのは exec と put と copy だけ)` };
|
|
43
48
|
}
|
|
44
49
|
const sep = argv.indexOf('--');
|
|
45
50
|
if (sep === -1) {
|
|
@@ -250,7 +255,7 @@ const PUT_VALUE_OPTIONS = ['--note', '--username', '--url', '--file'];
|
|
|
250
255
|
*/
|
|
251
256
|
export function parsePutArgs(argv) {
|
|
252
257
|
if (argv[0] !== 'put')
|
|
253
|
-
return { ok: false, help: false, error: '不明な操作です(受けるのは exec と put だけ)' };
|
|
258
|
+
return { ok: false, help: false, error: '不明な操作です(受けるのは exec と put と copy だけ)' };
|
|
254
259
|
const rest = argv.slice(1);
|
|
255
260
|
if (rest.length === 0 || rest.includes('-h') || rest.includes('--help'))
|
|
256
261
|
return { ok: false, help: true, error: '' };
|
|
@@ -422,3 +427,88 @@ export async function runSecretPut(argv, deps) {
|
|
|
422
427
|
deps.stderr(`${PREFIX}${name}: 保存しました(${value.length} 文字・読み戻して一致)\n`);
|
|
423
428
|
return 0;
|
|
424
429
|
}
|
|
430
|
+
export async function runSecretCopy(argv, deps) {
|
|
431
|
+
const parsed = parseCopyArgs(argv);
|
|
432
|
+
if (!parsed.ok) {
|
|
433
|
+
if (parsed.help) {
|
|
434
|
+
deps.stderr(USAGE);
|
|
435
|
+
return argv.length <= 1 ? 2 : 0;
|
|
436
|
+
}
|
|
437
|
+
deps.stderr(`${PREFIX}${parsed.error}\n${USAGE}`);
|
|
438
|
+
return 2;
|
|
439
|
+
}
|
|
440
|
+
const query = parsed.name;
|
|
441
|
+
const support = detectDialogSupport({ ...deps.host, env: deps.env });
|
|
442
|
+
if (!support.ok) {
|
|
443
|
+
deps.stderr(`${PREFIX}確認の窓を出せないのでコピーしません(${support.reason})\n`);
|
|
444
|
+
return 4;
|
|
445
|
+
}
|
|
446
|
+
const token = deps.loadToken();
|
|
447
|
+
if (!token) {
|
|
448
|
+
deps.stderr(`${PREFIX}未ログインです(bluelamp1 などでログインしてから撃ってください)\n`);
|
|
449
|
+
return 1;
|
|
450
|
+
}
|
|
451
|
+
const baseUrl = resolvePortalApiBaseUrl(deps.env);
|
|
452
|
+
let entries = null;
|
|
453
|
+
try {
|
|
454
|
+
const res = await deps.fetch(`${baseUrl}/vault/list`, { method: 'GET', headers: cliHeaders(token) });
|
|
455
|
+
if (!res.ok) {
|
|
456
|
+
deps.stderr(`${PREFIX}金庫の一覧を引けませんでした(${describeVaultStatus(res.status)})\n`);
|
|
457
|
+
return 1;
|
|
458
|
+
}
|
|
459
|
+
entries = readVaultList(await res.json());
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
entries = null;
|
|
463
|
+
}
|
|
464
|
+
if (entries === null) {
|
|
465
|
+
deps.stderr(`${PREFIX}金庫の一覧を読めませんでした\n`);
|
|
466
|
+
return 1;
|
|
467
|
+
}
|
|
468
|
+
const match = matchVaultName(entries, query);
|
|
469
|
+
if (match.kind === 'none') {
|
|
470
|
+
deps.stderr(`${PREFIX}${query}: 金庫に当たる名前がありません\n`);
|
|
471
|
+
return 1;
|
|
472
|
+
}
|
|
473
|
+
const backend = deps.backend(support.kind);
|
|
474
|
+
let name;
|
|
475
|
+
if (match.kind === 'exact') {
|
|
476
|
+
const answer = await backend.dialog(confirmPrompt(match.name), null);
|
|
477
|
+
if (answer.kind === 'nogui')
|
|
478
|
+
return refuseNoGui(deps);
|
|
479
|
+
if (answer.kind !== 'copy')
|
|
480
|
+
return cancelled(deps);
|
|
481
|
+
name = match.name;
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
const answer = await backend.dialog(choosePrompt(query, match.names.length, match.total), match.names);
|
|
485
|
+
if (answer.kind === 'nogui')
|
|
486
|
+
return refuseNoGui(deps);
|
|
487
|
+
if (answer.kind !== 'pick' || match.names[answer.index] === undefined)
|
|
488
|
+
return cancelled(deps);
|
|
489
|
+
name = match.names[answer.index];
|
|
490
|
+
}
|
|
491
|
+
const got = await fetchSecretValue(deps.fetch, baseUrl, token, name);
|
|
492
|
+
if (!got.ok) {
|
|
493
|
+
deps.stderr(`${PREFIX}${name}: ${got.reason}(コピーしていません)\n`);
|
|
494
|
+
return 1;
|
|
495
|
+
}
|
|
496
|
+
if (!(await backend.write(got.value))) {
|
|
497
|
+
deps.stderr(`${PREFIX}${name}: クリップボードに入れられませんでした\n`);
|
|
498
|
+
return 1;
|
|
499
|
+
}
|
|
500
|
+
if (!(await deps.scheduleClear(support.kind, got.value, CLEAR_AFTER_SECONDS))) {
|
|
501
|
+
deps.stderr(`${PREFIX}${name}: コピーしました。ただし ${CLEAR_AFTER_SECONDS} 秒後に消す仕掛けを起こせませんでした(使い終わったら別の物をコピーして上書きしてください)\n`);
|
|
502
|
+
return 0;
|
|
503
|
+
}
|
|
504
|
+
deps.stderr(`${PREFIX}${name}: コピーしました(${CLEAR_AFTER_SECONDS} 秒たつとクリップボードから消えます)\n`);
|
|
505
|
+
return 0;
|
|
506
|
+
}
|
|
507
|
+
function refuseNoGui(deps) {
|
|
508
|
+
deps.stderr(`${PREFIX}確認の窓を出せないのでコピーしません(画面に窓を出せませんでした)\n`);
|
|
509
|
+
return 4;
|
|
510
|
+
}
|
|
511
|
+
function cancelled(deps) {
|
|
512
|
+
deps.stderr(`${PREFIX}やめました(クリップボードは変えていません)\n`);
|
|
513
|
+
return 3;
|
|
514
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bluelamp/secret-exec",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "金庫の値を子プロセスの環境変数にだけ入れてコマンドを実行し(exec)、標準入力から金庫へ入れる(put)。値を画面・引数・ログに出さない口",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "金庫の値を子プロセスの環境変数にだけ入れてコマンドを実行し(exec)、標準入力から金庫へ入れる(put)・持ち主の確認の窓でクリップボードへ入れる(copy)。値を画面・引数・ログに出さない口",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bluelamp",
|
|
7
7
|
"cli",
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
"type": "module",
|
|
13
13
|
"exports": {
|
|
14
14
|
"./package.json": "./package.json",
|
|
15
|
-
"./handoff":
|
|
15
|
+
"./handoff": {
|
|
16
|
+
"types": "./dist/handoff.d.ts",
|
|
17
|
+
"default": "./dist/handoff.js"
|
|
18
|
+
}
|
|
16
19
|
},
|
|
17
20
|
"bin": {
|
|
18
21
|
"bluelamp-secret": "./bin/bluelamp-secret.mjs"
|