@bluelamp/secret-exec 0.3.1 → 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/index.d.ts +20 -1
- package/dist/index.js +94 -4
- package/package.json +2 -2
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
|
+
}
|
package/dist/index.d.ts
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,8 @@
|
|
|
19
20
|
*
|
|
20
21
|
* 判定と組み立ては純粋関数(unit で直接撃つ)、網と子プロセスは `runSecretExec` の deps に寄せる。
|
|
21
22
|
*/
|
|
23
|
+
import type { ClipboardBackend } from './clipboard.js';
|
|
24
|
+
import { type DialogKind, type HostInfo } from './copy.js';
|
|
22
25
|
/** ENV 名として受ける形。⛔ これ以外(小文字・記号・先頭数字)は受けない */
|
|
23
26
|
export declare const ENV_NAME_PATTERN: RegExp;
|
|
24
27
|
/** 既定のポータル API(bluelamp CLI の portal-api-client・TokenManager と同じ値) */
|
|
@@ -135,4 +138,20 @@ export interface SecretPutDeps {
|
|
|
135
138
|
}
|
|
136
139
|
/** put を実行する。戻り値は終了コード(0 保存して一致/1 失敗/2 引数・入力の形/3 既に在る) */
|
|
137
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>;
|
|
138
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",
|