mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 22:15:33 +00:00
fix(codex-auth): close remaining review gaps
This commit is contained in:
1 parent
08fe63c626
commit
5c79df4311
20 files changed
+823
-93
No files matched your search
@@ -17,6 +17,24 @@
|
||||
|
||||
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
|
||||
|
||||
function isCodexAuthProfileResolutionError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
(err as { name?: unknown }).name === 'CodexAuthProfileResolutionError'
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'object' && err !== null && 'message' in err) {
|
||||
const message = (err as { message?: unknown }).message;
|
||||
if (typeof message === 'string') return message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry-point for the ccsx / codex-runtime binary.
|
||||
*
|
||||
@@ -62,8 +80,8 @@ export async function main(argv: string[]): Promise<number> {
|
||||
}
|
||||
}
|
||||
} catch (resolverErr) {
|
||||
const msg = resolverErr instanceof Error ? resolverErr.message : String(resolverErr);
|
||||
if (resolverErr instanceof Error && resolverErr.name === 'CodexAuthProfileResolutionError') {
|
||||
const msg = errorMessage(resolverErr);
|
||||
if (isCodexAuthProfileResolutionError(resolverErr)) {
|
||||
process.stderr.write(`[X] codex-auth: ${msg}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a running `codex` process via pgrep (best-effort, never throws).
|
||||
* Detect a running `codex` process via pgrep + ps validation (best-effort, never throws).
|
||||
* Returns the PID string if found, null otherwise.
|
||||
*/
|
||||
function detectCodexRunning(): string | null {
|
||||
@@ -47,15 +47,83 @@ function detectCodexRunning(): string | null {
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
});
|
||||
if (result.status === 0 && result.stdout && result.stdout.trim().length > 0) {
|
||||
return result.stdout.trim().split('\n')[0];
|
||||
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
const pids = parsePgrepPids(result.stdout);
|
||||
if (pids.length === 0) return null;
|
||||
|
||||
const psResult = childProcess.spawnSync(
|
||||
'ps',
|
||||
['-p', pids.join(','), '-o', 'pid=', '-o', 'command='],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
}
|
||||
);
|
||||
if (psResult.status !== 0 || !psResult.stdout) return null;
|
||||
return selectCodexPidFromPsOutput(psResult.stdout, pids);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePgrepPids(stdout: string): string[] {
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((pid) => /^\d+$/.test(pid) && pid !== String(process.pid));
|
||||
}
|
||||
|
||||
function selectCodexPidFromPsOutput(stdout: string, candidatePids: string[]): string | null {
|
||||
const candidates = new Set(candidatePids);
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
|
||||
if (!match) continue;
|
||||
const [, pid, command] = match;
|
||||
if (!pid || !command || !candidates.has(pid)) continue;
|
||||
if (isLikelyCodexProcessCommand(command)) return pid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLikelyCodexProcessCommand(command: string): boolean {
|
||||
const executable = firstCommandToken(command);
|
||||
if (isCodexExecutableToken(executable)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = command.replace(/\\/g, '/').toLowerCase();
|
||||
if (normalized.includes('/@openai/codex/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return command.split(/\s+/).some((token) => {
|
||||
const tokenName = executableTokenBasename(token);
|
||||
return tokenName === 'codex.js' || isCodexExecutableName(tokenName);
|
||||
});
|
||||
}
|
||||
|
||||
function firstCommandToken(command: string): string {
|
||||
const trimmed = command.trim();
|
||||
const quoted = trimmed.match(/^"([^"]+)"/);
|
||||
if (quoted?.[1]) return quoted[1];
|
||||
return trimmed.split(/\s+/)[0] ?? '';
|
||||
}
|
||||
|
||||
function isCodexExecutableToken(token: string): boolean {
|
||||
return isCodexExecutableName(executableTokenBasename(token));
|
||||
}
|
||||
|
||||
function executableTokenBasename(token: string): string {
|
||||
return path.basename(token.replace(/^["']|["']$/g, '').replace(/\\/g, '/')).toLowerCase();
|
||||
}
|
||||
|
||||
function isCodexExecutableName(name: string): boolean {
|
||||
return ['codex', 'codex.exe', 'codex.cmd', 'codex.ps1'].includes(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate auth.json with retry on torn-write (C3).
|
||||
* Returns parsed auth JSON or throws on persistent failure.
|
||||
|
||||
@@ -113,6 +113,9 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
|
||||
const stagedDeleteDir = `${profileDir}.deleting.${process.pid}.${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
const preservationDir = `${profileDir}.preserved.${process.pid}.${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
|
||||
try {
|
||||
fs.renameSync(profileDir, stagedDeleteDir);
|
||||
@@ -125,10 +128,25 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.cpSync(stagedDeleteDir, preservationDir, { recursive: true, errorOnExist: true });
|
||||
} catch (err) {
|
||||
const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
|
||||
_removePathBestEffort(preservationDir);
|
||||
const preservedPath = restored ? profileDir : stagedDeleteDir;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
exitWithError(
|
||||
`Profile data delete preparation failed; profile data was preserved at ${preservedPath}.\n ${msg}`,
|
||||
ExitCode.GENERAL_ERROR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
registry.removeProfile(profileName);
|
||||
} catch (err) {
|
||||
const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
|
||||
_removePathBestEffort(preservationDir);
|
||||
const preservedPath = restored ? profileDir : stagedDeleteDir;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
exitWithError(
|
||||
@@ -140,9 +158,14 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
|
||||
|
||||
try {
|
||||
fs.rmSync(stagedDeleteDir, { recursive: true, force: true });
|
||||
fs.rmSync(preservationDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
const restoredDir = _restoreProfileDir(stagedDeleteDir, profileDir);
|
||||
const restoreSource = fs.existsSync(preservationDir) ? preservationDir : stagedDeleteDir;
|
||||
const restoredDir = _restoreProfileDir(restoreSource, profileDir);
|
||||
const restoredRegistry = _restoreRegistryEntry(registry, profileName, meta, originalDefault);
|
||||
if (restoredDir && restoreSource === preservationDir) {
|
||||
_removePathBestEffort(stagedDeleteDir);
|
||||
}
|
||||
const preservedPath = restoredDir ? profileDir : stagedDeleteDir;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const registryNote = restoredRegistry
|
||||
@@ -158,6 +181,14 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
|
||||
console.log(ok(`Profile removed: ${profileName}`));
|
||||
}
|
||||
|
||||
function _removePathBestEffort(targetPath: string): void {
|
||||
try {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup after data has already been preserved elsewhere
|
||||
}
|
||||
}
|
||||
|
||||
function _restoreRegistryEntry(
|
||||
registry: CodexCommandContext['registry'],
|
||||
profileName: string,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { decodeAccountIdentity } from '../codex-account-identity';
|
||||
import { showProfileDetail } from './show-detail-view';
|
||||
import { parseArgs, rejectUnsupportedOptions, formatRelativeTime } from './types';
|
||||
import type { CodexCommandContext, CodexProfileOutput } from './types';
|
||||
import type { CodexAccountIdentity } from '../types';
|
||||
|
||||
export async function handleShowCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
@@ -44,6 +45,7 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
|
||||
name: string;
|
||||
email: string;
|
||||
plan: string;
|
||||
accountId: string | null;
|
||||
lastUsed: string;
|
||||
state: string;
|
||||
missing?: boolean;
|
||||
@@ -57,6 +59,7 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
|
||||
name: activeName ?? '',
|
||||
email: '<unknown>',
|
||||
plan: '-',
|
||||
accountId: null,
|
||||
lastUsed: 'never',
|
||||
state: 'active(missing)',
|
||||
missing: true,
|
||||
@@ -71,15 +74,16 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
|
||||
|
||||
const profileDir = resolveCodexProfileDir(name);
|
||||
const authJsonPath = path.join(profileDir, 'auth.json');
|
||||
let email = meta.email ?? '<unknown>';
|
||||
if (fs.existsSync(authJsonPath) && !meta.email) {
|
||||
const identity = decodeAccountIdentity(authJsonPath);
|
||||
email = identity.email ?? '<unknown>';
|
||||
}
|
||||
const identity: CodexAccountIdentity = fs.existsSync(authJsonPath)
|
||||
? decodeAccountIdentity(authJsonPath)
|
||||
: {};
|
||||
const email = meta.email ?? identity.email ?? '<unknown>';
|
||||
const plan = meta.plan_type ?? identity.plan_type ?? '-';
|
||||
const accountId = meta.account_id ?? identity.account_id ?? null;
|
||||
|
||||
const lastUsed = meta.last_used ? formatRelativeTime(new Date(meta.last_used)) : 'never';
|
||||
|
||||
rows.push({ name, email, plan: meta.plan_type ?? '-', lastUsed, state: states.join(',') });
|
||||
rows.push({ name, email, plan, accountId, lastUsed, state: states.join(',') });
|
||||
}
|
||||
|
||||
if (json) {
|
||||
@@ -94,7 +98,7 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
|
||||
last_used: meta?.last_used ?? null,
|
||||
email: r.email === '<unknown>' ? null : r.email,
|
||||
plan: r.plan === '-' ? null : r.plan,
|
||||
account_id: null,
|
||||
account_id: r.accountId,
|
||||
profile_dir: profileDir,
|
||||
auth_json_exists: r.missing ? false : fs.existsSync(path.join(profileDir, 'auth.json')),
|
||||
auth_json_mtime: null,
|
||||
|
||||
@@ -68,6 +68,7 @@ export function showProfileDetail(
|
||||
if (isDefault) states.push('default');
|
||||
if (isActive) states.push('active');
|
||||
const stateStr = states.join(',');
|
||||
const accountId = meta.account_id ?? identity.account_id ?? null;
|
||||
|
||||
if (json) {
|
||||
const out: CodexProfileOutput = {
|
||||
@@ -78,7 +79,7 @@ export function showProfileDetail(
|
||||
last_used: meta.last_used ?? null,
|
||||
email: identity.email ?? null,
|
||||
plan: meta.plan_type ?? null,
|
||||
account_id: identity.account_id ?? null,
|
||||
account_id: accountId,
|
||||
profile_dir: profileDir,
|
||||
auth_json_exists: authExists,
|
||||
auth_json_mtime: authMtime,
|
||||
@@ -99,7 +100,7 @@ export function showProfileDetail(
|
||||
['auth.json', authState],
|
||||
['Email', identity.email ?? (authExists ? '<invalid>' : '<unknown>')],
|
||||
['Plan', meta.plan_type ?? (authExists ? '<invalid>' : '<unknown>')],
|
||||
['Account ID', identity.account_id ?? '-'],
|
||||
['Account ID', accountId ?? '-'],
|
||||
['Created', new Date(meta.created).toLocaleString()],
|
||||
['Last used', meta.last_used ? new Date(meta.last_used).toLocaleString() : 'never'],
|
||||
['CODEX_HOME (env)', process.env.CODEX_HOME ?? 'unset'],
|
||||
|
||||
@@ -31,7 +31,7 @@ function base64urlDecode(str: string): string {
|
||||
}
|
||||
|
||||
function isBase64UrlSegment(str: string): boolean {
|
||||
return str.length > 0 && BASE64URL_SEGMENT_RE.test(str);
|
||||
return str.length > 0 && str.length % 4 !== 1 && BASE64URL_SEGMENT_RE.test(str);
|
||||
}
|
||||
|
||||
function decodeJsonSegment(str: string): unknown {
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { getCodexAuthRegistryPath, resolveCodexProfileDir } from './codex-profile-paths';
|
||||
import { getCcsDirSource } from '../utils/config-manager';
|
||||
|
||||
export interface ResolvedProfile {
|
||||
name: string;
|
||||
@@ -27,16 +28,41 @@ interface RegistryShape {
|
||||
profiles?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function quoteDiagnosticValue(value: string): string {
|
||||
const escaped = value
|
||||
.replace(/[\x00-\x1f\x7f]/g, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
||||
.replace(/'/g, "\\'");
|
||||
return `'${escaped.length > 96 ? `${escaped.slice(0, 96)}...` : escaped}'`;
|
||||
}
|
||||
|
||||
function registryDisplayPath(registryPath: string): string {
|
||||
const [source] = getCcsDirSource();
|
||||
if (source === 'default') {
|
||||
return process.platform === 'win32'
|
||||
? '%USERPROFILE%\\.ccs\\codex-profiles.yaml'
|
||||
: '~/.ccs/codex-profiles.yaml';
|
||||
}
|
||||
if (source === 'CCS_HOME' || source === 'scoped:CCS_HOME') {
|
||||
return '$CCS_HOME/.ccs/codex-profiles.yaml';
|
||||
}
|
||||
if (source === 'CCS_DIR' || source === 'scoped:CCS_DIR') {
|
||||
return '$CCS_DIR/codex-profiles.yaml';
|
||||
}
|
||||
return registryPath;
|
||||
}
|
||||
|
||||
/** @param env - Process env map; defaults to process.env. Injectable for tests. */
|
||||
export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): ResolvedProfile | null {
|
||||
const registryPath = getCodexAuthRegistryPath();
|
||||
const envName = (env.CCS_CODEX_PROFILE ?? '').trim();
|
||||
const displayEnvName = quoteDiagnosticValue(envName);
|
||||
const displayRegistryPath = registryDisplayPath(registryPath);
|
||||
|
||||
// F4: silent fallback — no registry means no profiles, legacy mode
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
if (envName) {
|
||||
throw new CodexAuthProfileResolutionError(
|
||||
`CCS_CODEX_PROFILE='${envName}' is set but ${registryPath} does not exist. Refusing to fall back to ~/.codex.`
|
||||
`CCS_CODEX_PROFILE=${displayEnvName} is set but ${displayRegistryPath} does not exist. Refusing to fall back to ~/.codex.`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -47,10 +73,10 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
|
||||
const raw = fs.readFileSync(registryPath, 'utf8');
|
||||
const parsed = yaml.load(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
const msg = `registry at ${registryPath} is not a valid YAML object`;
|
||||
const msg = `registry at ${displayRegistryPath} is not a valid YAML object`;
|
||||
if (envName) {
|
||||
throw new CodexAuthProfileResolutionError(
|
||||
`CCS_CODEX_PROFILE='${envName}' is set but ${msg}. Refusing to fall back to ~/.codex.`
|
||||
`CCS_CODEX_PROFILE=${displayEnvName} is set but ${msg}. Refusing to fall back to ~/.codex.`
|
||||
);
|
||||
}
|
||||
process.stderr.write(`[!] codex-auth: ${msg}, falling back to ~/.codex\n`);
|
||||
@@ -59,15 +85,13 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
|
||||
registry = parsed as RegistryShape;
|
||||
} catch (err) {
|
||||
if (err instanceof CodexAuthProfileResolutionError) throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = `registry YAML could not be parsed at ${displayRegistryPath}`;
|
||||
if (envName) {
|
||||
throw new CodexAuthProfileResolutionError(
|
||||
`CCS_CODEX_PROFILE='${envName}' is set but registry YAML is corrupt at ${registryPath} (${msg}). Refusing to fall back to ~/.codex.`
|
||||
`CCS_CODEX_PROFILE=${displayEnvName} is set but ${msg}. Refusing to fall back to ~/.codex.`
|
||||
);
|
||||
}
|
||||
process.stderr.write(
|
||||
`[!] codex-auth: registry YAML corrupt at ${registryPath} (${msg}), falling back to ~/.codex\n`
|
||||
);
|
||||
process.stderr.write(`[!] codex-auth: ${msg}, falling back to ~/.codex\n`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -77,7 +101,7 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
|
||||
if (envName) {
|
||||
if (!Object.prototype.hasOwnProperty.call(profiles, envName)) {
|
||||
throw new CodexAuthProfileResolutionError(
|
||||
`CCS_CODEX_PROFILE='${envName}' not found in registry. Refusing to fall back to ~/.codex.`
|
||||
`CCS_CODEX_PROFILE=${displayEnvName} not found in registry. Refusing to fall back to ~/.codex.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -3,19 +3,27 @@
|
||||
* Determines current shell to emit correct eval-safe export syntax.
|
||||
*/
|
||||
|
||||
import * as childProcess from 'child_process';
|
||||
|
||||
export type Shell = 'bash' | 'zsh' | 'fish' | 'pwsh' | 'cmd';
|
||||
|
||||
/**
|
||||
* Detect current shell from environment.
|
||||
* On Windows: PSModulePath presence → pwsh, else cmd.
|
||||
* On Windows: inspect explicit shell executable hints, else default to cmd.
|
||||
* On Unix: inspect $SHELL suffix.
|
||||
*/
|
||||
export function detectShell(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: string = process.platform
|
||||
platform: string = process.platform,
|
||||
parentProcessName?: string
|
||||
): Shell {
|
||||
if (platform === 'win32') {
|
||||
return env.PSModulePath ? 'pwsh' : 'cmd';
|
||||
return (
|
||||
shellFromExecutable(env.SHELL) ??
|
||||
shellFromExecutable(parentProcessName ?? detectParentProcessName(platform)) ??
|
||||
shellFromExecutable(env.ComSpec ?? env.COMSPEC) ??
|
||||
'cmd'
|
||||
);
|
||||
}
|
||||
const sh = (env.SHELL ?? '').toLowerCase();
|
||||
if (sh.endsWith('/fish')) return 'fish';
|
||||
@@ -23,6 +31,50 @@ export function detectShell(
|
||||
return 'bash'; // default for bash, sh, dash, ksh
|
||||
}
|
||||
|
||||
function detectParentProcessName(platform: string): string | undefined {
|
||||
if (platform !== 'win32' || !Number.isInteger(process.ppid) || process.ppid <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = childProcess.spawnSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
`(Get-Process -Id ${process.ppid} -ErrorAction Stop).ProcessName`,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 1500, windowsHide: true }
|
||||
);
|
||||
|
||||
if (result.status !== 0 || !result.stdout) return undefined;
|
||||
return result.stdout.trim().split(/\r?\n/).pop()?.trim();
|
||||
}
|
||||
|
||||
function shellFromExecutable(value: string | undefined): Shell | null {
|
||||
if (!value) return null;
|
||||
const base = value
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.pop()
|
||||
?.toLowerCase()
|
||||
.replace(/\.(exe|cmd|ps1|bat)$/i, '');
|
||||
|
||||
switch (base) {
|
||||
case 'fish':
|
||||
case 'zsh':
|
||||
case 'bash':
|
||||
case 'cmd':
|
||||
return base;
|
||||
case 'pwsh':
|
||||
case 'powershell':
|
||||
return 'pwsh';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-quote escape for POSIX shells (bash/zsh/fish).
|
||||
* Closes the single-quote, inserts escaped quote, reopens.
|
||||
@@ -33,11 +85,11 @@ function posixSingleQuote(value: string): string {
|
||||
|
||||
/**
|
||||
* Double-quote escape for PowerShell.
|
||||
* Wraps in double quotes; escapes internal double quotes by doubling them
|
||||
* and backtick-escapes $ to prevent variable interpolation.
|
||||
* Wraps in double quotes; escapes the PowerShell escape char first, doubles
|
||||
* internal double quotes, and backtick-escapes $ to prevent interpolation.
|
||||
*/
|
||||
function pwshDoubleQuote(value: string): string {
|
||||
return '"' + value.replace(/"/g, '""').replace(/\$/g, '`$') + '"';
|
||||
return '"' + value.replace(/`/g, '``').replace(/"/g, '""').replace(/\$/g, '`$') + '"';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +97,7 @@ function pwshDoubleQuote(value: string): string {
|
||||
* like &, |, <, and > inside the assignment instead of executing them.
|
||||
*/
|
||||
function cmdSetQuote(value: string): string {
|
||||
return value.replace(/\^/g, '^^').replace(/%/g, '%%').replace(/"/g, '^"');
|
||||
return value.replace(/\^/g, '^^').replace(/%/g, '%%').replace(/"/g, '^"').replace(/!/g, '^^!');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user