@ciphrix/cli 0.1.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/LICENSE +201 -0
- package/README.md +201 -0
- package/dist/actions.d.ts +19 -0
- package/dist/actions.js +30 -0
- package/dist/api.d.ts +27 -0
- package/dist/api.js +180 -0
- package/dist/banner.d.ts +2 -0
- package/dist/banner.js +12 -0
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +44 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +1579 -0
- package/dist/commands/asset.d.ts +59 -0
- package/dist/commands/asset.js +237 -0
- package/dist/commands/check.d.ts +30 -0
- package/dist/commands/check.js +182 -0
- package/dist/commands/context.d.ts +23 -0
- package/dist/commands/context.js +102 -0
- package/dist/commands/control.d.ts +44 -0
- package/dist/commands/control.js +189 -0
- package/dist/commands/framework.d.ts +40 -0
- package/dist/commands/framework.js +181 -0
- package/dist/commands/job.d.ts +16 -0
- package/dist/commands/job.js +30 -0
- package/dist/commands/login.d.ts +12 -0
- package/dist/commands/login.js +54 -0
- package/dist/commands/logout.d.ts +9 -0
- package/dist/commands/logout.js +56 -0
- package/dist/commands/policy.d.ts +23 -0
- package/dist/commands/policy.js +112 -0
- package/dist/commands/policyActions.d.ts +37 -0
- package/dist/commands/policyActions.js +177 -0
- package/dist/commands/risk.d.ts +51 -0
- package/dist/commands/risk.js +289 -0
- package/dist/commands/test.d.ts +54 -0
- package/dist/commands/test.js +331 -0
- package/dist/commands/vendor.d.ts +45 -0
- package/dist/commands/vendor.js +218 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +56 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.js +7 -0
- package/dist/credentials.d.ts +19 -0
- package/dist/credentials.js +182 -0
- package/dist/deviceFlow.d.ts +32 -0
- package/dist/deviceFlow.js +115 -0
- package/dist/deviceInfo.d.ts +11 -0
- package/dist/deviceInfo.js +25 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/io.d.ts +46 -0
- package/dist/io.js +99 -0
- package/dist/output.d.ts +14 -0
- package/dist/output.js +37 -0
- package/dist/theme.d.ts +25 -0
- package/dist/theme.js +39 -0
- package/dist/toolSurface.d.ts +20 -0
- package/dist/toolSurface.js +49 -0
- package/dist/upload.d.ts +18 -0
- package/dist/upload.js +115 -0
- package/package.json +71 -0
- package/skills/ciphrix/SKILL.md +169 -0
- package/skills/ciphrix/references/commands.md +1592 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/** Public client identifier registered with the Ciphrix device-authorization endpoint. */
|
|
4
|
+
export const CLI_CLIENT_ID = 'ciphrix-cli';
|
|
5
|
+
/** Production API base. Override with `--api-url` or `CIPHRIX_API_URL`. */
|
|
6
|
+
export const DEFAULT_API_BASE_URL = 'https://global.api.ciphrix.app/api';
|
|
7
|
+
let cliAllowsInsecureHttp = false;
|
|
8
|
+
/** Used by the CLI hooks to scope the command-line opt-in to one command invocation. */
|
|
9
|
+
export const setCliInsecureHttpOptIn = (allowed) => {
|
|
10
|
+
cliAllowsInsecureHttp = allowed;
|
|
11
|
+
};
|
|
12
|
+
export const normalizeBaseUrl = (value) => {
|
|
13
|
+
const trimmed = value.trim();
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(trimmed);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error(`Invalid API URL: ${value}`);
|
|
20
|
+
}
|
|
21
|
+
const authority = trimmed.match(/^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i)?.[1] ?? '';
|
|
22
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
23
|
+
throw new Error('Invalid API URL: only HTTPS URLs are supported (HTTP requires explicit opt-in)');
|
|
24
|
+
}
|
|
25
|
+
if (!url.hostname ||
|
|
26
|
+
authority.includes('@') ||
|
|
27
|
+
url.username ||
|
|
28
|
+
url.password ||
|
|
29
|
+
url.search ||
|
|
30
|
+
url.hash ||
|
|
31
|
+
trimmed.includes('?') ||
|
|
32
|
+
trimmed.includes('#')) {
|
|
33
|
+
throw new Error('Invalid API URL: provide a hostname and optional path without credentials, query, or fragment');
|
|
34
|
+
}
|
|
35
|
+
url.pathname = url.pathname.replace(/\/+$/, '') || '/';
|
|
36
|
+
return url
|
|
37
|
+
.toString()
|
|
38
|
+
.replace(/\/$/, url.pathname === '/' ? '' : '/')
|
|
39
|
+
.replace(/\/$/, '');
|
|
40
|
+
};
|
|
41
|
+
export const resolveApiBaseUrl = ({ flag, env = process.env, } = {}) => {
|
|
42
|
+
const candidate = flag || env.CIPHRIX_API_URL || DEFAULT_API_BASE_URL;
|
|
43
|
+
const normalized = normalizeBaseUrl(candidate);
|
|
44
|
+
const parsed = new URL(normalized);
|
|
45
|
+
const envAllowsInsecureHttp = env.CIPHRIX_ALLOW_INSECURE_HTTP === '1' ||
|
|
46
|
+
env.CIPHRIX_ALLOW_INSECURE_HTTP?.toLowerCase() === 'true';
|
|
47
|
+
if (parsed.protocol === 'http:' && !envAllowsInsecureHttp && !cliAllowsInsecureHttp) {
|
|
48
|
+
throw new Error('Refusing to send credentials or data over HTTP. Use HTTPS, or explicitly opt in for local testing with --allow-insecure-http or CIPHRIX_ALLOW_INSECURE_HTTP=true.');
|
|
49
|
+
}
|
|
50
|
+
return normalized;
|
|
51
|
+
};
|
|
52
|
+
export const configDir = (env = process.env) => {
|
|
53
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
54
|
+
const base = xdg && xdg.trim() !== '' ? xdg : join(homedir(), '.config');
|
|
55
|
+
return join(base, 'ciphrix');
|
|
56
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product identity for the CLI. Kept in one place so commands, help text and
|
|
3
|
+
* the banner never drift from each other.
|
|
4
|
+
*/
|
|
5
|
+
export declare const CLI_NAME = "ciphrix";
|
|
6
|
+
export declare const CLI_TAGLINE = "Ciphrix compliance platform CLI";
|
|
7
|
+
export declare const CLI_VERSION = "0.1.0";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product identity for the CLI. Kept in one place so commands, help text and
|
|
3
|
+
* the banner never drift from each other.
|
|
4
|
+
*/
|
|
5
|
+
export const CLI_NAME = 'ciphrix';
|
|
6
|
+
export const CLI_TAGLINE = 'Ciphrix compliance platform CLI';
|
|
7
|
+
export const CLI_VERSION = '0.1.0';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface Credential {
|
|
2
|
+
apiUrl: string;
|
|
3
|
+
token: string;
|
|
4
|
+
}
|
|
5
|
+
export interface CredentialStore {
|
|
6
|
+
get(apiUrl: string): Promise<Credential | null>;
|
|
7
|
+
set(credential: Credential): Promise<void>;
|
|
8
|
+
clear(apiUrl: string): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export interface CredentialStoreOptions {
|
|
11
|
+
directory?: string;
|
|
12
|
+
forceFile?: boolean;
|
|
13
|
+
env?: NodeJS.ProcessEnv;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Uses the OS keychain by default. The less secure file store is only used when
|
|
17
|
+
* explicitly selected with `CIPHRIX_CREDENTIAL_STORE=file` or `forceFile`.
|
|
18
|
+
*/
|
|
19
|
+
export declare const createCredentialStore: (options?: CredentialStoreOptions) => Promise<CredentialStore>;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { chmod, lstat, mkdir, open, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { configDir } from './config.js';
|
|
5
|
+
const KEYRING_SERVICE = 'ciphrix-cli';
|
|
6
|
+
const keychainError = () => new Error('The OS keychain is unavailable or could not be accessed. No credential was read, saved, or removed. ' +
|
|
7
|
+
'For local or headless testing only, explicitly set CIPHRIX_CREDENTIAL_STORE=file to use the ' +
|
|
8
|
+
'permission-restricted file store.');
|
|
9
|
+
const loadEntryFactory = async () => {
|
|
10
|
+
try {
|
|
11
|
+
const keyring = await import('@napi-rs/keyring');
|
|
12
|
+
return (service, account) => new keyring.Entry(service, account);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw keychainError();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const createKeyringStore = (entryFactory) => ({
|
|
19
|
+
get: (apiUrl) => {
|
|
20
|
+
try {
|
|
21
|
+
const token = entryFactory(KEYRING_SERVICE, apiUrl).getPassword();
|
|
22
|
+
return Promise.resolve(token ? { apiUrl, token } : null);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return Promise.reject(keychainError());
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
set: (credential) => {
|
|
29
|
+
try {
|
|
30
|
+
entryFactory(KEYRING_SERVICE, credential.apiUrl).setPassword(credential.token);
|
|
31
|
+
return Promise.resolve();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return Promise.reject(keychainError());
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
clear: (apiUrl) => {
|
|
38
|
+
try {
|
|
39
|
+
entryFactory(KEYRING_SERVICE, apiUrl).deletePassword();
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return Promise.reject(keychainError());
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const fileStoreError = (action) => new Error(`Unable to ${action} the credential file. Check its permissions and integrity.`);
|
|
48
|
+
const ensurePrivateDirectory = async (directory) => {
|
|
49
|
+
try {
|
|
50
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
51
|
+
const info = await lstat(directory);
|
|
52
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
53
|
+
throw fileStoreError('access');
|
|
54
|
+
await chmod(directory, 0o700);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw fileStoreError('secure');
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const isMissing = (error) => typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
|
61
|
+
const parseCredentials = (contents) => {
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(contents);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw fileStoreError('read');
|
|
68
|
+
}
|
|
69
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
70
|
+
throw fileStoreError('read');
|
|
71
|
+
}
|
|
72
|
+
const credentials = Object.create(null);
|
|
73
|
+
for (const [apiUrl, value] of Object.entries(parsed)) {
|
|
74
|
+
if (apiUrl.length === 0 ||
|
|
75
|
+
typeof value !== 'object' ||
|
|
76
|
+
value === null ||
|
|
77
|
+
Array.isArray(value) ||
|
|
78
|
+
typeof value.token !== 'string' ||
|
|
79
|
+
value.token.length === 0) {
|
|
80
|
+
throw fileStoreError('read');
|
|
81
|
+
}
|
|
82
|
+
Object.defineProperty(credentials, apiUrl, {
|
|
83
|
+
value: { token: value.token },
|
|
84
|
+
enumerable: true,
|
|
85
|
+
configurable: true,
|
|
86
|
+
writable: true,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return credentials;
|
|
90
|
+
};
|
|
91
|
+
const createFileStore = (directory) => {
|
|
92
|
+
const path = join(directory, 'credentials.json');
|
|
93
|
+
let operationQueue = Promise.resolve();
|
|
94
|
+
const serialized = (operation) => {
|
|
95
|
+
const result = operationQueue.then(operation);
|
|
96
|
+
operationQueue = result.then(() => undefined, () => undefined);
|
|
97
|
+
return result;
|
|
98
|
+
};
|
|
99
|
+
const read = async () => {
|
|
100
|
+
await ensurePrivateDirectory(dirname(path));
|
|
101
|
+
let file;
|
|
102
|
+
try {
|
|
103
|
+
try {
|
|
104
|
+
const pathInfo = await lstat(path);
|
|
105
|
+
if (pathInfo.isSymbolicLink() || !pathInfo.isFile())
|
|
106
|
+
throw fileStoreError('read');
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (!isMissing(error))
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
113
|
+
file = await open(path, constants.O_RDONLY | noFollow);
|
|
114
|
+
const info = await file.stat();
|
|
115
|
+
if (!info.isFile())
|
|
116
|
+
throw fileStoreError('read');
|
|
117
|
+
await file.chmod(0o600);
|
|
118
|
+
return parseCredentials(await file.readFile('utf8'));
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (isMissing(error))
|
|
122
|
+
return Object.create(null);
|
|
123
|
+
throw fileStoreError('read');
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await file?.close().catch(() => undefined);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const write = async (data) => {
|
|
130
|
+
await ensurePrivateDirectory(dirname(path));
|
|
131
|
+
const temporaryPath = join(dirname(path), `.credentials-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`);
|
|
132
|
+
let handle;
|
|
133
|
+
try {
|
|
134
|
+
handle = await open(temporaryPath, 'wx', 0o600);
|
|
135
|
+
await handle.writeFile(`${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
136
|
+
await handle.sync();
|
|
137
|
+
await handle.close();
|
|
138
|
+
handle = undefined;
|
|
139
|
+
await rename(temporaryPath, path);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
await handle?.close().catch(() => undefined);
|
|
143
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
144
|
+
throw fileStoreError('write');
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
get: (apiUrl) => serialized(async () => {
|
|
149
|
+
const entry = (await read())[apiUrl];
|
|
150
|
+
return entry ? { apiUrl, token: entry.token } : null;
|
|
151
|
+
}),
|
|
152
|
+
set: (credential) => serialized(async () => {
|
|
153
|
+
if (!credential.apiUrl || !credential.token)
|
|
154
|
+
throw fileStoreError('write');
|
|
155
|
+
const data = await read();
|
|
156
|
+
Object.defineProperty(data, credential.apiUrl, {
|
|
157
|
+
value: { token: credential.token },
|
|
158
|
+
enumerable: true,
|
|
159
|
+
configurable: true,
|
|
160
|
+
writable: true,
|
|
161
|
+
});
|
|
162
|
+
await write(data);
|
|
163
|
+
}),
|
|
164
|
+
clear: (apiUrl) => serialized(async () => {
|
|
165
|
+
const data = await read();
|
|
166
|
+
if (!Object.hasOwn(data, apiUrl))
|
|
167
|
+
return;
|
|
168
|
+
delete data[apiUrl];
|
|
169
|
+
await write(data);
|
|
170
|
+
}),
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* Uses the OS keychain by default. The less secure file store is only used when
|
|
175
|
+
* explicitly selected with `CIPHRIX_CREDENTIAL_STORE=file` or `forceFile`.
|
|
176
|
+
*/
|
|
177
|
+
export const createCredentialStore = async (options = {}) => {
|
|
178
|
+
const { directory = configDir(options.env), forceFile = false, env = process.env } = options;
|
|
179
|
+
if (forceFile || env.CIPHRIX_CREDENTIAL_STORE === 'file')
|
|
180
|
+
return createFileStore(directory);
|
|
181
|
+
return createKeyringStore(await loadEntryFactory());
|
|
182
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type ApiClient } from './api.js';
|
|
2
|
+
import type { DeviceIdentity } from './deviceInfo.js';
|
|
3
|
+
export declare const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
4
|
+
export declare const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
|
5
|
+
export declare const MIN_POLL_INTERVAL_SECONDS = 1;
|
|
6
|
+
export declare const MAX_INITIAL_POLL_INTERVAL_SECONDS = 60;
|
|
7
|
+
export declare const MAX_POLL_INTERVAL_SECONDS = 300;
|
|
8
|
+
export declare const MAX_DEVICE_CODE_LIFETIME_SECONDS = 3600;
|
|
9
|
+
export interface DeviceCodeRequest {
|
|
10
|
+
deviceCode: string;
|
|
11
|
+
userCode: string;
|
|
12
|
+
verificationUri: string;
|
|
13
|
+
verificationUriComplete: string | null;
|
|
14
|
+
intervalSeconds: number;
|
|
15
|
+
expiresInSeconds: number;
|
|
16
|
+
}
|
|
17
|
+
export declare const requestDeviceCode: (client: ApiClient, clientId?: string, identity?: DeviceIdentity) => Promise<DeviceCodeRequest>;
|
|
18
|
+
export interface PollOptions {
|
|
19
|
+
client: ApiClient;
|
|
20
|
+
deviceCode: string;
|
|
21
|
+
intervalSeconds: number;
|
|
22
|
+
expiresInSeconds: number;
|
|
23
|
+
clientId?: string;
|
|
24
|
+
sleep?: (ms: number) => Promise<void>;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
onPoll?: () => void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Polls the token endpoint until the user approves, the code expires, or the
|
|
30
|
+
* request is denied. Honours `authorization_pending` and `slow_down` per RFC 8628.
|
|
31
|
+
*/
|
|
32
|
+
export declare const pollForToken: ({ client, deviceCode, intervalSeconds, expiresInSeconds, clientId, sleep, now, onPoll, }: PollOptions) => Promise<string>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { ApiError } from './api.js';
|
|
2
|
+
import { CLI_CLIENT_ID } from './config.js';
|
|
3
|
+
export const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
4
|
+
export const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
|
5
|
+
export const MIN_POLL_INTERVAL_SECONDS = 1;
|
|
6
|
+
export const MAX_INITIAL_POLL_INTERVAL_SECONDS = 60;
|
|
7
|
+
export const MAX_POLL_INTERVAL_SECONDS = 300;
|
|
8
|
+
export const MAX_DEVICE_CODE_LIFETIME_SECONDS = 3600;
|
|
9
|
+
const asRecord = (value) => value && typeof value === 'object' ? value : {};
|
|
10
|
+
const asString = (value, fallback = '') => typeof value === 'string' ? value : fallback;
|
|
11
|
+
const boundedInteger = (value, label, min, max, fallback, clampMaximum = true) => {
|
|
12
|
+
if (value === undefined && fallback !== undefined)
|
|
13
|
+
return fallback;
|
|
14
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min) {
|
|
15
|
+
throw new Error(`The server returned an invalid ${label}.`);
|
|
16
|
+
}
|
|
17
|
+
if (value > max && !clampMaximum)
|
|
18
|
+
throw new Error(`The server returned an unsupported ${label}.`);
|
|
19
|
+
return Math.min(value, max);
|
|
20
|
+
};
|
|
21
|
+
const verificationUrl = (value, label) => {
|
|
22
|
+
let url;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(value);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error(`The server returned an invalid ${label}.`);
|
|
28
|
+
}
|
|
29
|
+
const isLoopback = url.hostname === 'localhost' ||
|
|
30
|
+
url.hostname === '[::1]' ||
|
|
31
|
+
/^127(?:\.\d{1,3}){3}$/.test(url.hostname);
|
|
32
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)) ||
|
|
33
|
+
url.username ||
|
|
34
|
+
url.password) {
|
|
35
|
+
throw new Error(`The server returned an invalid ${label}.`);
|
|
36
|
+
}
|
|
37
|
+
return url.href;
|
|
38
|
+
};
|
|
39
|
+
export const requestDeviceCode = async (client, clientId = CLI_CLIENT_ID, identity = {}) => {
|
|
40
|
+
const body = { client_id: clientId };
|
|
41
|
+
if (identity.deviceName)
|
|
42
|
+
body.device_name = identity.deviceName;
|
|
43
|
+
if (identity.devicePlatform)
|
|
44
|
+
body.device_platform = identity.devicePlatform;
|
|
45
|
+
const data = asRecord(await client.request('/auth/device/code', { method: 'POST', body }));
|
|
46
|
+
const deviceCode = asString(data.device_code);
|
|
47
|
+
const userCode = asString(data.user_code);
|
|
48
|
+
const rawVerificationUri = asString(data.verification_uri);
|
|
49
|
+
if (!deviceCode || !userCode || !rawVerificationUri) {
|
|
50
|
+
throw new Error('The server did not return a device code.');
|
|
51
|
+
}
|
|
52
|
+
const verificationUri = verificationUrl(rawVerificationUri, 'verification URL');
|
|
53
|
+
const rawVerificationUriComplete = asString(data.verification_uri_complete);
|
|
54
|
+
const verificationUriComplete = rawVerificationUriComplete
|
|
55
|
+
? verificationUrl(rawVerificationUriComplete, 'complete verification URL')
|
|
56
|
+
: null;
|
|
57
|
+
const intervalSeconds = boundedInteger(data.interval, 'polling interval', MIN_POLL_INTERVAL_SECONDS, MAX_INITIAL_POLL_INTERVAL_SECONDS, DEFAULT_POLL_INTERVAL_SECONDS, false);
|
|
58
|
+
// RFC 8628 requires expires_in. There is no protocol default, so reject a missing or malformed
|
|
59
|
+
// lifetime instead of guessing and potentially polling after the authorization code expires.
|
|
60
|
+
const expiresInSeconds = boundedInteger(data.expires_in, 'device-code lifetime', 1, MAX_DEVICE_CODE_LIFETIME_SECONDS);
|
|
61
|
+
return {
|
|
62
|
+
deviceCode,
|
|
63
|
+
userCode,
|
|
64
|
+
verificationUri,
|
|
65
|
+
verificationUriComplete,
|
|
66
|
+
intervalSeconds,
|
|
67
|
+
expiresInSeconds,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Polls the token endpoint until the user approves, the code expires, or the
|
|
72
|
+
* request is denied. Honours `authorization_pending` and `slow_down` per RFC 8628.
|
|
73
|
+
*/
|
|
74
|
+
export const pollForToken = async ({ client, deviceCode, intervalSeconds, expiresInSeconds, clientId = CLI_CLIENT_ID, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = () => Date.now(), onPoll, }) => {
|
|
75
|
+
const safeInterval = boundedInteger(intervalSeconds, 'polling interval', MIN_POLL_INTERVAL_SECONDS, MAX_INITIAL_POLL_INTERVAL_SECONDS, undefined, false);
|
|
76
|
+
const safeLifetime = boundedInteger(expiresInSeconds, 'device-code lifetime', 1, MAX_DEVICE_CODE_LIFETIME_SECONDS);
|
|
77
|
+
const deadline = now() + safeLifetime * 1000;
|
|
78
|
+
let interval = safeInterval;
|
|
79
|
+
while (now() < deadline) {
|
|
80
|
+
const remainingMs = deadline - now();
|
|
81
|
+
if (remainingMs <= 0)
|
|
82
|
+
break;
|
|
83
|
+
await sleep(Math.min(interval * 1000, remainingMs));
|
|
84
|
+
if (now() >= deadline)
|
|
85
|
+
break;
|
|
86
|
+
try {
|
|
87
|
+
const data = asRecord(await client.request('/auth/device/token', {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
body: { grant_type: DEVICE_GRANT_TYPE, device_code: deviceCode, client_id: clientId },
|
|
90
|
+
}));
|
|
91
|
+
if (now() >= deadline)
|
|
92
|
+
break;
|
|
93
|
+
const token = asString(data.access_token);
|
|
94
|
+
if (!token)
|
|
95
|
+
throw new Error('The server did not return an access token.');
|
|
96
|
+
return token;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error instanceof ApiError && error.code === 'authorization_pending') {
|
|
100
|
+
onPoll?.();
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (error instanceof ApiError && error.code === 'slow_down') {
|
|
104
|
+
if (interval + 5 > MAX_POLL_INTERVAL_SECONDS) {
|
|
105
|
+
throw new Error('The authorization server is asking the CLI to poll too slowly. Run `ciphrix login` again later.', { cause: error });
|
|
106
|
+
}
|
|
107
|
+
interval += 5;
|
|
108
|
+
onPoll?.();
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
throw new Error('The device code expired before it was approved. Run `ciphrix login` again.');
|
|
115
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A friendly default device name derived from the machine hostname, so a fresh
|
|
3
|
+
* login is recognisable on the approval page without any setup.
|
|
4
|
+
*/
|
|
5
|
+
export declare const defaultDeviceName: () => string;
|
|
6
|
+
export declare const defaultDevicePlatform: () => string;
|
|
7
|
+
export interface DeviceIdentity {
|
|
8
|
+
deviceName?: string | undefined;
|
|
9
|
+
devicePlatform?: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
export declare const resolveDeviceIdentity: (deviceName?: string) => Required<DeviceIdentity>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { hostname, platform } from 'node:os';
|
|
2
|
+
const PLATFORM_LABELS = {
|
|
3
|
+
darwin: 'macOS',
|
|
4
|
+
win32: 'Windows',
|
|
5
|
+
linux: 'Linux',
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* A friendly default device name derived from the machine hostname, so a fresh
|
|
9
|
+
* login is recognisable on the approval page without any setup.
|
|
10
|
+
*/
|
|
11
|
+
export const defaultDeviceName = () => {
|
|
12
|
+
const raw = hostname()
|
|
13
|
+
.replace(/\.local$/i, '')
|
|
14
|
+
.replace(/[-_]+/g, ' ')
|
|
15
|
+
.trim();
|
|
16
|
+
return raw === '' ? 'This device' : raw;
|
|
17
|
+
};
|
|
18
|
+
export const defaultDevicePlatform = () => {
|
|
19
|
+
const label = PLATFORM_LABELS[platform()] ?? platform();
|
|
20
|
+
return `${label} · ${process.arch}`;
|
|
21
|
+
};
|
|
22
|
+
export const resolveDeviceIdentity = (deviceName) => ({
|
|
23
|
+
deviceName: deviceName?.trim() || defaultDeviceName(),
|
|
24
|
+
devicePlatform: defaultDevicePlatform(),
|
|
25
|
+
});
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/io.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Theme } from './theme.js';
|
|
2
|
+
/**
|
|
3
|
+
* Output targets for the CLI. Commands receive this rather than touching
|
|
4
|
+
* `process` directly, so they can be tested with captured streams.
|
|
5
|
+
*/
|
|
6
|
+
export interface CliIO {
|
|
7
|
+
readonly stdout: NodeJS.WriteStream;
|
|
8
|
+
readonly stderr: NodeJS.WriteStream;
|
|
9
|
+
readonly theme: Theme;
|
|
10
|
+
}
|
|
11
|
+
export declare const createDefaultIO: () => CliIO;
|
|
12
|
+
export declare const writeLine: (stream: NodeJS.WriteStream, text?: string) => void;
|
|
13
|
+
/** Writes a local, intentionally multiline block such as the fixed CLI banner. */
|
|
14
|
+
export declare const writeBlock: (stream: NodeJS.WriteStream, text: string) => void;
|
|
15
|
+
export declare const renderTerminalText: (text: string) => string;
|
|
16
|
+
/**
|
|
17
|
+
* Remove terminal controls from untrusted text.
|
|
18
|
+
* Newlines and carriage returns become spaces so a server value cannot forge extra output lines.
|
|
19
|
+
*/
|
|
20
|
+
export declare const sanitizeTerminalText: (value: string) => string;
|
|
21
|
+
/**
|
|
22
|
+
* Renders an aligned table. Column widths follow the content; the last column may overflow gracefully.
|
|
23
|
+
*/
|
|
24
|
+
export declare const writeTable: (io: CliIO, headers: string[], rows: string[][]) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Prints a pagination footer for a list. Makes truncation explicit and shows the next page command.
|
|
27
|
+
*/
|
|
28
|
+
export declare const writePageFooter: (io: CliIO, { shown, total, page, limit, hasMore, }: {
|
|
29
|
+
shown: number;
|
|
30
|
+
total: number;
|
|
31
|
+
page: number;
|
|
32
|
+
limit: number;
|
|
33
|
+
hasMore: boolean;
|
|
34
|
+
}) => void;
|
|
35
|
+
/**
|
|
36
|
+
* One dim line naming the drill-down for what was just listed, so the next step is obvious rather than
|
|
37
|
+
* guessed. Callers pass the concrete command; nothing is printed when there is nothing to drill into.
|
|
38
|
+
*/
|
|
39
|
+
export declare const writeNextHint: (io: CliIO, command: string | null | undefined) => void;
|
|
40
|
+
/** Clips a value to a column-friendly width. Asset names are often long cloud ARNs. */
|
|
41
|
+
export declare const clip: (value: string, max?: number) => string;
|
|
42
|
+
/**
|
|
43
|
+
* Asks a yes/no question. Returns false when there is no interactive terminal,
|
|
44
|
+
* so a non-`--yes` write never applies in a pipe or CI by accident.
|
|
45
|
+
*/
|
|
46
|
+
export declare const promptConfirm: (io: CliIO, question: string) => Promise<boolean>;
|
package/dist/io.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { createTheme, renderThemeStyles } from './theme.js';
|
|
3
|
+
export const createDefaultIO = () => ({
|
|
4
|
+
stdout: process.stdout,
|
|
5
|
+
stderr: process.stderr,
|
|
6
|
+
theme: createTheme(),
|
|
7
|
+
});
|
|
8
|
+
export const writeLine = (stream, text = '') => {
|
|
9
|
+
stream.write(`${safeOutputText(text)}\n`);
|
|
10
|
+
};
|
|
11
|
+
/** Writes a local, intentionally multiline block such as the fixed CLI banner. */
|
|
12
|
+
export const writeBlock = (stream, text) => {
|
|
13
|
+
stream.write(text
|
|
14
|
+
.split(/\r\n|[\r\n]/)
|
|
15
|
+
.map(safeOutputText)
|
|
16
|
+
.join('\n'));
|
|
17
|
+
};
|
|
18
|
+
const safeOutputText = (text) => {
|
|
19
|
+
try {
|
|
20
|
+
JSON.parse(text);
|
|
21
|
+
// JSON.stringify leaves C1 code points literal. Escape them in the serialized representation so
|
|
22
|
+
// consumers recover the original values while terminals never interpret them as controls.
|
|
23
|
+
return text.replace(/[\u0080-\u009F]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return renderTerminalText(text);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
export const renderTerminalText = (text) => renderThemeStyles(sanitizeTerminalText(text));
|
|
30
|
+
/**
|
|
31
|
+
* Remove terminal controls from untrusted text.
|
|
32
|
+
* Newlines and carriage returns become spaces so a server value cannot forge extra output lines.
|
|
33
|
+
*/
|
|
34
|
+
export const sanitizeTerminalText = (value) => {
|
|
35
|
+
// OSC, DCS, SOS, PM and APC strings may end with BEL or ST (7-bit or 8-bit).
|
|
36
|
+
const withoutStrings = value.replace(/(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\|\u009C)|\u009D[\s\S]*?(?:\u0007|\u001B\\|\u009C)|\u001B[P^_X][\s\S]*?(?:\u001B\\|\u009C)|[\u0090\u0098\u009E\u009F][\s\S]*?(?:\u001B\\|\u009C))/g, '');
|
|
37
|
+
// Theme styling uses private markers that are restored only after sanitization. Remove every
|
|
38
|
+
// actual CSI and ESC sequence here, including SGR sequences supplied by untrusted data.
|
|
39
|
+
const withoutControls = withoutStrings
|
|
40
|
+
.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '')
|
|
41
|
+
.replace(/\u009B[0-?]*[ -/]*[@-~]/g, '')
|
|
42
|
+
.replace(/\u001B(?!\[)[ -/]*[0-~]/g, '')
|
|
43
|
+
.replace(/\u001B(?!\[[0-9;]*m)/g, '')
|
|
44
|
+
.replace(/[\u0000-\u0009\u000B\u000C\u000E-\u001A\u001C-\u001F\u007F-\u009F]/g, '');
|
|
45
|
+
return withoutControls.replace(/\r\n|[\r\n]/g, ' ');
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Renders an aligned table. Column widths follow the content; the last column may overflow gracefully.
|
|
49
|
+
*/
|
|
50
|
+
export const writeTable = (io, headers, rows) => {
|
|
51
|
+
const safeHeaders = headers.map(sanitizeTerminalText);
|
|
52
|
+
const safeRows = rows.map((row) => row.map(sanitizeTerminalText));
|
|
53
|
+
const widths = safeHeaders.map((header, index) => Math.max(stripAnsi(header).length, ...safeRows.map((row) => stripAnsi(row[index] ?? '').length)));
|
|
54
|
+
const render = (cells) => cells
|
|
55
|
+
.map((cell, index) => index === cells.length - 1 ? cell : cell.padEnd((widths[index] ?? 0) + 2))
|
|
56
|
+
.join('');
|
|
57
|
+
writeLine(io.stdout, io.theme.dim(render(safeHeaders)));
|
|
58
|
+
for (const row of safeRows)
|
|
59
|
+
writeLine(io.stdout, render(row));
|
|
60
|
+
};
|
|
61
|
+
const stripAnsi = (value) => sanitizeTerminalText(value).replace(/\u001B\[[0-9;]*m/g, '');
|
|
62
|
+
/**
|
|
63
|
+
* Prints a pagination footer for a list. Makes truncation explicit and shows the next page command.
|
|
64
|
+
*/
|
|
65
|
+
export const writePageFooter = (io, { shown, total, page, limit, hasMore, }) => {
|
|
66
|
+
if (total <= 0)
|
|
67
|
+
return;
|
|
68
|
+
const first = (page - 1) * limit + 1;
|
|
69
|
+
const last = Math.min(first + shown - 1, total);
|
|
70
|
+
const range = shown === 0 ? '0' : `${first}–${last}`;
|
|
71
|
+
writeLine(io.stdout, io.theme.dim(`showing ${range} of ${total}${hasMore ? ` · next: --page ${page + 1}` : ''}`));
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* One dim line naming the drill-down for what was just listed, so the next step is obvious rather than
|
|
75
|
+
* guessed. Callers pass the concrete command; nothing is printed when there is nothing to drill into.
|
|
76
|
+
*/
|
|
77
|
+
export const writeNextHint = (io, command) => {
|
|
78
|
+
if (!command)
|
|
79
|
+
return;
|
|
80
|
+
writeLine(io.stdout, io.theme.dim(`next: ${command}`));
|
|
81
|
+
};
|
|
82
|
+
/** Clips a value to a column-friendly width. Asset names are often long cloud ARNs. */
|
|
83
|
+
export const clip = (value, max = 60) => value.length > max ? `${value.slice(0, max - 1)}\u2026` : value;
|
|
84
|
+
/**
|
|
85
|
+
* Asks a yes/no question. Returns false when there is no interactive terminal,
|
|
86
|
+
* so a non-`--yes` write never applies in a pipe or CI by accident.
|
|
87
|
+
*/
|
|
88
|
+
export const promptConfirm = async (io, question) => {
|
|
89
|
+
if (io.stdout.isTTY !== true)
|
|
90
|
+
return false;
|
|
91
|
+
const rl = createInterface({ input: process.stdin, output: io.stdout });
|
|
92
|
+
try {
|
|
93
|
+
const answer = await rl.question(`${sanitizeTerminalText(question)} [y/N] `);
|
|
94
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
rl.close();
|
|
98
|
+
}
|
|
99
|
+
};
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Theme } from './theme.js';
|
|
2
|
+
/**
|
|
3
|
+
* A small, honest status vocabulary shared by every command. `unknown` exists
|
|
4
|
+
* so a check that cannot be determined is never reported as success.
|
|
5
|
+
*/
|
|
6
|
+
export declare const STATUSES: readonly ["ok", "fail", "caution", "unknown"];
|
|
7
|
+
export type Status = (typeof STATUSES)[number];
|
|
8
|
+
export interface Output {
|
|
9
|
+
line(text?: string): void;
|
|
10
|
+
status(status: Status, label?: string): void;
|
|
11
|
+
row(name: string, detail: string, status: Status): void;
|
|
12
|
+
detail(text: string): void;
|
|
13
|
+
}
|
|
14
|
+
export declare const createOutput: (theme: Theme, stream?: NodeJS.WriteStream) => Output;
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { renderTerminalText } from './io.js';
|
|
2
|
+
/**
|
|
3
|
+
* A small, honest status vocabulary shared by every command. `unknown` exists
|
|
4
|
+
* so a check that cannot be determined is never reported as success.
|
|
5
|
+
*/
|
|
6
|
+
export const STATUSES = ['ok', 'fail', 'caution', 'unknown'];
|
|
7
|
+
const LABELS = {
|
|
8
|
+
ok: 'ok',
|
|
9
|
+
fail: 'fail',
|
|
10
|
+
caution: 'caution',
|
|
11
|
+
unknown: 'unknown',
|
|
12
|
+
};
|
|
13
|
+
export const createOutput = (theme, stream = process.stdout) => {
|
|
14
|
+
const write = (text) => {
|
|
15
|
+
stream.write(`${renderTerminalText(text)}\n`);
|
|
16
|
+
};
|
|
17
|
+
const paint = (status, label) => {
|
|
18
|
+
switch (status) {
|
|
19
|
+
case 'ok':
|
|
20
|
+
return theme.green(label);
|
|
21
|
+
case 'fail':
|
|
22
|
+
return theme.red(label);
|
|
23
|
+
case 'caution':
|
|
24
|
+
return theme.yellow(label);
|
|
25
|
+
case 'unknown':
|
|
26
|
+
return theme.grey(label);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
line: (text = '') => write(text),
|
|
31
|
+
status: (status, label = LABELS[status]) => write(paint(status, label)),
|
|
32
|
+
row: (name, detail, status) => {
|
|
33
|
+
write(`${theme.bold(name)}\n ${theme.dim(detail)} ${paint(status, LABELS[status])}`);
|
|
34
|
+
},
|
|
35
|
+
detail: (text) => write(` ${theme.dim(`↳ ${text}`)}`),
|
|
36
|
+
};
|
|
37
|
+
};
|