shortcutxl 0.4.0 → 0.4.2
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/BINARY-INVENTORY.json +12 -12
- package/CHANGELOG.md +6 -0
- package/dist/app/agent-session-runtime-adapter.d.ts +5 -0
- package/dist/app/agent-session-runtime-adapter.js +7 -4
- package/dist/app/agent-session.d.ts +7 -2
- package/dist/app/agent-session.js +9 -5
- package/dist/app/observability/client-logging/client-logger.d.ts +2 -1
- package/dist/app/observability/client-logging/client-logger.js +11 -5
- package/dist/app/providers/model-resolver.d.ts +6 -0
- package/dist/app/providers/model-resolver.js +23 -20
- package/dist/app/services/user-context.d.ts +2 -0
- package/dist/app/services/user-context.js +4 -1
- package/dist/app/session/account-default-model.d.ts +33 -0
- package/dist/app/session/account-default-model.js +53 -0
- package/dist/app/session/model-manager.d.ts +15 -4
- package/dist/app/session/model-manager.js +23 -8
- package/dist/app/session/session-lifecycle.js +14 -12
- package/dist/cli.js +1892 -956
- package/dist/config.d.ts +13 -5
- package/dist/config.js +37 -23
- package/dist/contracts/model-stream.d.ts +5 -0
- package/dist/contracts/wire.d.ts +3 -1
- package/dist/core/agent-engine.d.ts +2 -1
- package/dist/core/agent-engine.js +3 -0
- package/dist/core/agent-loop.js +8 -2
- package/dist/core/core-types.d.ts +3 -1
- package/dist/core/session/compaction/compaction.d.ts +2 -0
- package/dist/core/session/compaction/compaction.js +3 -0
- package/dist/core/session/compaction-bridge.d.ts +2 -1
- package/dist/core/session/compaction-bridge.js +4 -1
- package/dist/embedded-agent/compaction-wiring.js +4 -1
- package/dist/embedded-agent/compose.js +12 -4
- package/dist/main.d.ts +7 -0
- package/dist/main.js +21 -17
- package/dist/rpc/rpc-host.js +7 -1
- package/dist/shell/interactive/auth-workflow.js +7 -1
- package/dist/shortcut-default-model.d.ts +30 -0
- package/dist/shortcut-default-model.js +86 -0
- package/dist/startup/terminal-update-flow.js +4 -1
- package/dist/startup/update-action.d.ts +17 -2
- package/dist/startup/update-action.js +114 -40
- package/dist/startup/update-diagnostics.d.ts +85 -0
- package/dist/startup/update-diagnostics.js +188 -0
- package/dist/startup/update-execution.d.ts +13 -1
- package/dist/startup/update-execution.js +205 -6
- package/dist/startup/update-installation.d.ts +55 -0
- package/dist/startup/update-installation.js +242 -0
- package/dist/startup/update-manager.d.ts +5 -0
- package/dist/startup/update-manager.js +23 -5
- package/dist/startup/update-verification.d.ts +87 -0
- package/dist/startup/update-verification.js +204 -0
- package/dist/supported-models.generated.js +1 -1
- package/native-app/main/chunks/{config-CwtqLhpn.js → config-DSoKsgPT.js} +1 -1
- package/native-app/main/chunks/{linux-C5J5oKJy.js → linux-TBGGGecs.js} +3 -3
- package/native-app/main/chunks/{mac-Bt_bFwUg.js → mac-Dg93p_2s.js} +3 -3
- package/native-app/main/chunks/{setup-worker-protocol-QUVpcuEb.js → setup-worker-protocol-CHVmPxBP.js} +54 -35
- package/native-app/main/chunks/{shared-Q59yhreZ.js → shared-BbAcLTbB.js} +1 -1
- package/native-app/main/index.js +190 -16
- package/native-app/main/setup-worker.js +3 -3
- package/native-app/package.json +1 -1
- package/native-app/renderer/assets/{index-DkKrXRl6.js → index-BJUrz0xE.js} +2210 -167
- package/native-app/renderer/assets/server-PLrRTws-.js +2689 -0
- package/native-app/renderer/index.html +1 -1
- package/package.json +1 -1
- package/xll/python/Lib/site-packages/httpx-0.28.1.dist-info/RECORD +1 -1
- package/xll/python/Lib/site-packages/idna-3.18.dist-info/RECORD +1 -1
- package/xll/python/Lib/site-packages/pip-26.2.dist-info/RECORD +3 -3
- package/xll/python/Lib/site-packages/pywin32-311.dist-info/RECORD +2 -2
- package/xll/python/Scripts/httpx.exe +0 -0
- package/xll/python/Scripts/idna.exe +0 -0
- package/xll/python/Scripts/pip.exe +0 -0
- package/xll/python/Scripts/pip3.13.exe +0 -0
- package/xll/python/Scripts/pip3.exe +0 -0
- package/xll/python/Scripts/pywin32_postinstall.exe +0 -0
- package/xll/python/Scripts/pywin32_testall.exe +0 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-aware default model for the Shortcut provider.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the web app's `getPlanDefaultPickerEntry` (client/shared context-size.ts):
|
|
5
|
+
* Team accounts use the org-admin-saved picker key (retired keys follow their
|
|
6
|
+
* successor) and fall back to the product default picker entry; unpaid personal
|
|
7
|
+
* accounts use the Free model; everyone else, including a signed-out runtime,
|
|
8
|
+
* uses the product default picker entry (`defaults.pickerKey`).
|
|
9
|
+
*
|
|
10
|
+
* Candidates are only models the CLI/desktop picker can list (`product.cli`), so
|
|
11
|
+
* a default can never land on a model the user cannot see or pick again.
|
|
12
|
+
* `defaults.runtime` / `SHORTCUT_OPENAI_MODEL_ID` are deliberately not used here:
|
|
13
|
+
* they identify the OpenAI reasoning model for fast mode and BYOK providers.
|
|
14
|
+
*/
|
|
15
|
+
import { CATALOG_FREE_MODEL_ID, MODEL_CATALOG } from './supported-models.generated.js';
|
|
16
|
+
import { getSupportedModel, getSupportedModelByPickerKey, SUPPORTED_MODELS } from './supported-models.js';
|
|
17
|
+
const SHORTCUT_PROVIDER = 'shortcut';
|
|
18
|
+
function isPickerListedModelId(modelId) {
|
|
19
|
+
return getSupportedModel(modelId)?.product.cli === true;
|
|
20
|
+
}
|
|
21
|
+
/** Model id behind a picker key, following a retired key to its successor; picker-listed only. */
|
|
22
|
+
export function shortcutPickerKeyModelId(key) {
|
|
23
|
+
if (!key)
|
|
24
|
+
return undefined;
|
|
25
|
+
const direct = getSupportedModelByPickerKey(key);
|
|
26
|
+
if (direct && isPickerListedModelId(direct.id))
|
|
27
|
+
return direct.id;
|
|
28
|
+
const successor = MODEL_CATALOG.retiredPickerKeys[key];
|
|
29
|
+
const replacement = successor ? getSupportedModelByPickerKey(successor) : undefined;
|
|
30
|
+
return replacement && isPickerListedModelId(replacement.id) ? replacement.id : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** Picker-listed models in product picker order, used after the plan default. */
|
|
33
|
+
const PICKER_ORDER_MODEL_IDS = SUPPORTED_MODELS.flatMap((model) => model.product.pickerOrder !== undefined && model.product.cli
|
|
34
|
+
? [{ id: model.id, order: model.product.pickerOrder }]
|
|
35
|
+
: [])
|
|
36
|
+
.sort((a, b) => a.order - b.order)
|
|
37
|
+
.map((model) => model.id);
|
|
38
|
+
/**
|
|
39
|
+
* The product default picker entry's model (GPT-6 Astra today). If a catalog edit ever makes
|
|
40
|
+
* `defaults.pickerKey` a model the CLI picker does not list, fall back to the first listed
|
|
41
|
+
* picker entry rather than failing at import; a unit test pins the catalog value.
|
|
42
|
+
*/
|
|
43
|
+
export const SHORTCUT_PRODUCT_DEFAULT_MODEL_ID = shortcutPickerKeyModelId(MODEL_CATALOG.defaults.pickerKey) ??
|
|
44
|
+
PICKER_ORDER_MODEL_IDS[0] ??
|
|
45
|
+
CATALOG_FREE_MODEL_ID;
|
|
46
|
+
/** True when the account is Free: users/me reported no paid plan and no team. */
|
|
47
|
+
function isFreePlan(plan) {
|
|
48
|
+
return !plan.teamId && plan.plan === null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Ordered default candidates for an account. The first one the account can run wins;
|
|
52
|
+
* every candidate is a picker-listed model.
|
|
53
|
+
*/
|
|
54
|
+
export function getShortcutDefaultModelCandidates(plan = {}) {
|
|
55
|
+
const preferred = [];
|
|
56
|
+
if (plan.teamId) {
|
|
57
|
+
preferred.push(shortcutPickerKeyModelId(plan.defaultModelPickerEntryKey));
|
|
58
|
+
preferred.push(SHORTCUT_PRODUCT_DEFAULT_MODEL_ID);
|
|
59
|
+
}
|
|
60
|
+
else if (isFreePlan(plan)) {
|
|
61
|
+
preferred.push(CATALOG_FREE_MODEL_ID);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
preferred.push(SHORTCUT_PRODUCT_DEFAULT_MODEL_ID);
|
|
65
|
+
}
|
|
66
|
+
const ordered = [...preferred, ...PICKER_ORDER_MODEL_IDS, CATALOG_FREE_MODEL_ID];
|
|
67
|
+
return [...new Set(ordered.filter(isPickerListedModelId))];
|
|
68
|
+
}
|
|
69
|
+
/** Pick the plan default from the models the account can run now (Shortcut provider only). */
|
|
70
|
+
export function resolveShortcutDefaultModel(availableModels, plan = getShortcutDefaultModelPlan()) {
|
|
71
|
+
for (const id of getShortcutDefaultModelCandidates(plan)) {
|
|
72
|
+
const match = availableModels.find((model) => model.provider === SHORTCUT_PROVIDER && model.id === id);
|
|
73
|
+
if (match)
|
|
74
|
+
return match;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
let currentPlan = () => ({});
|
|
79
|
+
/** Hosts set this from users/me so startup and login defaults follow the account's plan. */
|
|
80
|
+
export function setShortcutDefaultModelPlan(plan) {
|
|
81
|
+
currentPlan = plan;
|
|
82
|
+
}
|
|
83
|
+
export function getShortcutDefaultModelPlan() {
|
|
84
|
+
return currentPlan();
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=shortcut-default-model.js.map
|
|
@@ -8,6 +8,9 @@ export async function runTerminalUpdateFlow(options, deps = {}) {
|
|
|
8
8
|
const result = await (deps.executeUpdate ?? executeStartupUpdate)(outcome.update);
|
|
9
9
|
if (result.kind === 'success')
|
|
10
10
|
return { kind: 'updated' };
|
|
11
|
-
|
|
11
|
+
// An unverified update exited 0 but did not take effect; it must not exit 0 or launch.
|
|
12
|
+
if (result.kind === 'unverified')
|
|
13
|
+
return { kind: 'update-failed', exitCode: 1 };
|
|
14
|
+
return { kind: 'update-failed', exitCode: result.exitCode || 1 };
|
|
12
15
|
}
|
|
13
16
|
//# sourceMappingURL=terminal-update-flow.js.map
|
|
@@ -7,14 +7,23 @@
|
|
|
7
7
|
* streamed directly into the user's terminal.
|
|
8
8
|
*/
|
|
9
9
|
import { spawn } from 'child_process';
|
|
10
|
+
import { type UpdateFailureDiagnostics } from './update-diagnostics.js';
|
|
10
11
|
export interface UpdateLaunchRequest {
|
|
11
12
|
command: string;
|
|
12
13
|
cwd?: string;
|
|
14
|
+
/** Resolve npm's original global config before rebinding its install prefix. */
|
|
15
|
+
preserveNpmConfig?: boolean;
|
|
16
|
+
/** Joins the debug-log section to the attempt's receipt. */
|
|
17
|
+
attemptId?: string;
|
|
13
18
|
}
|
|
14
|
-
export interface ForegroundUpdateResult {
|
|
15
|
-
|
|
19
|
+
export interface ForegroundUpdateResult extends UpdateFailureDiagnostics {
|
|
20
|
+
/** `unverified`: the installer exited 0 but the next `shortcut` would not run the target. */
|
|
21
|
+
kind: 'success' | 'failed' | 'unverified';
|
|
16
22
|
exitCode: number | null;
|
|
17
23
|
signal: NodeJS.Signals | null;
|
|
24
|
+
/** Sanitized installer stdout/stderr, kept on success too so warnings are not lost. */
|
|
25
|
+
output?: string;
|
|
26
|
+
outputTruncated?: boolean;
|
|
18
27
|
message?: string;
|
|
19
28
|
logPath?: string;
|
|
20
29
|
}
|
|
@@ -26,7 +35,13 @@ interface UpdateActionDeps {
|
|
|
26
35
|
};
|
|
27
36
|
resolveEnv?: () => NodeJS.ProcessEnv;
|
|
28
37
|
resolveLogPath?: () => string;
|
|
38
|
+
readNpmGlobalConfig?: typeof readNpmGlobalConfig;
|
|
29
39
|
}
|
|
30
40
|
export declare function runForegroundUpdate(request: UpdateLaunchRequest, deps?: UpdateActionDeps): Promise<ForegroundUpdateResult>;
|
|
41
|
+
/** Read only the path, never registry credentials or the contents of an npmrc. */
|
|
42
|
+
declare function readNpmGlobalConfig(shell: {
|
|
43
|
+
shell: string;
|
|
44
|
+
args: string[];
|
|
45
|
+
}, env: NodeJS.ProcessEnv, cwd: string): Promise<string>;
|
|
31
46
|
export {};
|
|
32
47
|
//# sourceMappingURL=update-action.d.ts.map
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* half-deleted, so stdout/stderr are captured and summarized instead of being
|
|
7
7
|
* streamed directly into the user's terminal.
|
|
8
8
|
*/
|
|
9
|
-
import { spawn } from 'child_process';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { execFile, spawn } from 'child_process';
|
|
10
|
+
import { isAbsolute } from 'path';
|
|
11
|
+
import { promisify } from 'util';
|
|
12
12
|
import { getShellConfig, getShellEnv } from '../app/tools/bash-runtime.js';
|
|
13
13
|
import { getDebugLogPath } from '../config.js';
|
|
14
|
+
import { appendUpdateLog, sanitizeInstallerOutput } from './update-diagnostics.js';
|
|
14
15
|
const MAX_CAPTURED_OUTPUT_BYTES = 1024 * 1024;
|
|
15
16
|
export async function runForegroundUpdate(request, deps = {}) {
|
|
16
17
|
const resolveShell = deps.resolveShell ?? getShellConfig;
|
|
@@ -18,13 +19,57 @@ export async function runForegroundUpdate(request, deps = {}) {
|
|
|
18
19
|
const spawnProcess = deps.spawnProcess ?? spawn;
|
|
19
20
|
const resolveLogPath = deps.resolveLogPath ?? getDebugLogPath;
|
|
20
21
|
const shellConfig = resolveShell();
|
|
22
|
+
const cwd = request.cwd ?? process.cwd();
|
|
23
|
+
const env = { ...resolveEnv() };
|
|
24
|
+
if (request.preserveNpmConfig) {
|
|
25
|
+
let invalidPath = false;
|
|
26
|
+
try {
|
|
27
|
+
// Query with the same shell/environment as the installer, before --prefix changes
|
|
28
|
+
// npm's default globalconfig. --global excludes a project .npmrc just as install -g does.
|
|
29
|
+
const globalConfig = await (deps.readNpmGlobalConfig ?? readNpmGlobalConfig)(shellConfig, env, cwd);
|
|
30
|
+
if (!isAbsolute(globalConfig) || /[\r\n\0]/.test(globalConfig)) {
|
|
31
|
+
invalidPath = true;
|
|
32
|
+
throw new Error('Invalid npm global configuration path');
|
|
33
|
+
}
|
|
34
|
+
// Windows environment names are case-insensitive. Do not leave competing spellings.
|
|
35
|
+
for (const key of Object.keys(env)) {
|
|
36
|
+
if (key.toLowerCase() === 'npm_config_globalconfig')
|
|
37
|
+
delete env[key];
|
|
38
|
+
}
|
|
39
|
+
env.npm_config_globalconfig = globalConfig;
|
|
40
|
+
}
|
|
41
|
+
catch (cause) {
|
|
42
|
+
const diagnostics = {
|
|
43
|
+
failure_stage: 'npm_config',
|
|
44
|
+
...classifyConfigProbeFailure(cause, invalidPath)
|
|
45
|
+
};
|
|
46
|
+
const logPath = appendUpdateLog({
|
|
47
|
+
title: 'ShortcutXL update configuration failure',
|
|
48
|
+
fields: {
|
|
49
|
+
attemptId: request.attemptId,
|
|
50
|
+
command: request.command,
|
|
51
|
+
failure: 'npm-globalconfig-probe-failed',
|
|
52
|
+
...diagnostics
|
|
53
|
+
}
|
|
54
|
+
}, 'The original npm global configuration could not be resolved. No installer was started.', resolveLogPath);
|
|
55
|
+
return {
|
|
56
|
+
kind: 'failed',
|
|
57
|
+
...diagnostics,
|
|
58
|
+
logPath,
|
|
59
|
+
exitCode: null,
|
|
60
|
+
signal: null,
|
|
61
|
+
message: 'ShortcutXL could not read your npm global configuration, so no update was attempted. ' +
|
|
62
|
+
'Run `npm config get globalconfig --global` in this terminal, resolve any npm configuration error, and retry.'
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
21
66
|
return await new Promise((resolve, reject) => {
|
|
22
67
|
const output = [];
|
|
23
68
|
let outputBytes = 0;
|
|
24
69
|
let outputTruncated = false;
|
|
25
70
|
const child = spawnProcess(shellConfig.shell, [...shellConfig.args, request.command], {
|
|
26
|
-
cwd
|
|
27
|
-
env
|
|
71
|
+
cwd,
|
|
72
|
+
env,
|
|
28
73
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
29
74
|
});
|
|
30
75
|
const capture = (chunk) => {
|
|
@@ -42,70 +87,99 @@ export async function runForegroundUpdate(request, deps = {}) {
|
|
|
42
87
|
child.stderr?.on('data', capture);
|
|
43
88
|
child.once('error', reject);
|
|
44
89
|
child.once('close', (exitCode, signal) => {
|
|
90
|
+
const capturedOutput = sanitizeInstallerOutput(Buffer.concat(output).toString('utf-8'));
|
|
45
91
|
if (exitCode === 0) {
|
|
46
92
|
resolve({
|
|
47
93
|
kind: 'success',
|
|
48
94
|
exitCode,
|
|
49
|
-
signal
|
|
95
|
+
signal,
|
|
96
|
+
output: capturedOutput,
|
|
97
|
+
outputTruncated
|
|
50
98
|
});
|
|
51
99
|
return;
|
|
52
100
|
}
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
101
|
+
const logPath = appendUpdateLog({
|
|
102
|
+
title: 'ShortcutXL update failure',
|
|
103
|
+
fields: {
|
|
104
|
+
attemptId: request.attemptId,
|
|
105
|
+
command: request.command,
|
|
106
|
+
exitCode: String(exitCode),
|
|
107
|
+
signal: String(signal),
|
|
108
|
+
outputTruncatedAfterBytes: outputTruncated ? MAX_CAPTURED_OUTPUT_BYTES : undefined
|
|
109
|
+
}
|
|
110
|
+
}, capturedOutput, resolveLogPath);
|
|
62
111
|
resolve({
|
|
63
112
|
kind: 'failed',
|
|
64
113
|
exitCode,
|
|
65
114
|
signal,
|
|
66
|
-
|
|
115
|
+
output: capturedOutput,
|
|
116
|
+
outputTruncated,
|
|
117
|
+
message: summarizeUpdateFailure(capturedOutput, request.command, request.preserveNpmConfig),
|
|
67
118
|
logPath
|
|
68
119
|
});
|
|
69
120
|
});
|
|
70
121
|
});
|
|
71
122
|
}
|
|
72
|
-
function summarizeUpdateFailure(output, updateCommand) {
|
|
123
|
+
function summarizeUpdateFailure(output, updateCommand, preserveNpmConfig = false) {
|
|
124
|
+
const retry = preserveNpmConfig
|
|
125
|
+
? 'Restart Shortcut and select Update now to retry with your npm configuration.'
|
|
126
|
+
: `Retry with \`${updateCommand}\`.`;
|
|
73
127
|
if (/EEXIST[\s\S]*shortcut\.cmd/i.test(output) ||
|
|
74
128
|
/File exists:[\s\S]*shortcut\.cmd/i.test(output)) {
|
|
75
129
|
return [
|
|
76
130
|
'ShortcutXL could not update because an existing global `shortcut` command blocked npm from replacing it.',
|
|
77
|
-
|
|
131
|
+
'Close any running ShortcutXL terminals and remove the existing global ShortcutXL package.',
|
|
132
|
+
retry
|
|
78
133
|
].join(' ');
|
|
79
134
|
}
|
|
80
135
|
if (/EPERM|ENOTEMPTY|ENOENT/i.test(output) &&
|
|
81
136
|
/AppData[\\/]+Roaming[\\/]+npm[\\/]+node_modules[\\/]+shortcutxl/i.test(output)) {
|
|
82
137
|
return [
|
|
83
138
|
'ShortcutXL could not update because npm could not clean up the previous global install.',
|
|
84
|
-
|
|
139
|
+
'Close ShortcutXL terminals.',
|
|
140
|
+
retry
|
|
85
141
|
].join(' ');
|
|
86
142
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
appendFileSync(logPath, [
|
|
94
|
-
'',
|
|
95
|
-
'--- ShortcutXL update failure ---',
|
|
96
|
-
`time=${new Date().toISOString()}`,
|
|
97
|
-
`command=${command}`,
|
|
98
|
-
`exitCode=${String(exitCode)}`,
|
|
99
|
-
`signal=${String(signal)}`,
|
|
100
|
-
truncated ? `outputTruncatedAfterBytes=${MAX_CAPTURED_OUTPUT_BYTES}` : undefined,
|
|
101
|
-
output
|
|
102
|
-
]
|
|
103
|
-
.filter((line) => line !== undefined)
|
|
104
|
-
.join('\n'), 'utf-8');
|
|
105
|
-
return logPath;
|
|
143
|
+
if (/ETARGET|No matching version found for shortcutxl@/i.test(output)) {
|
|
144
|
+
return [
|
|
145
|
+
'ShortcutXL could not update because your configured package registry does not have this release yet.',
|
|
146
|
+
`Check the registry with \`${process.platform === 'win32' ? 'npm.cmd' : 'npm'} config get registry\` and ask your administrator to sync ShortcutXL.`,
|
|
147
|
+
retry
|
|
148
|
+
].join(' ');
|
|
106
149
|
}
|
|
107
|
-
|
|
108
|
-
|
|
150
|
+
return `ShortcutXL could not update automatically. ${retry}`;
|
|
151
|
+
}
|
|
152
|
+
/** Read only the path, never registry credentials or the contents of an npmrc. */
|
|
153
|
+
async function readNpmGlobalConfig(shell, env, cwd) {
|
|
154
|
+
const { stdout } = await promisify(execFile)(shell.shell, [...shell.args, 'npm config get globalconfig --global'], {
|
|
155
|
+
cwd,
|
|
156
|
+
env,
|
|
157
|
+
encoding: 'utf8',
|
|
158
|
+
timeout: 15_000,
|
|
159
|
+
maxBuffer: 64 * 1024,
|
|
160
|
+
windowsHide: true
|
|
161
|
+
});
|
|
162
|
+
return stdout.trim();
|
|
163
|
+
}
|
|
164
|
+
/** Only inspect process metadata. Probe messages/output can contain private npm configuration. */
|
|
165
|
+
function classifyConfigProbeFailure(cause, invalidPath) {
|
|
166
|
+
if (invalidPath)
|
|
167
|
+
return { failure_class: 'invalid_config_path' };
|
|
168
|
+
const error = cause && typeof cause === 'object'
|
|
169
|
+
? cause
|
|
170
|
+
: {};
|
|
171
|
+
if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER')
|
|
172
|
+
return { failure_class: 'probe_output_limit' };
|
|
173
|
+
if (error.code === 'ENOENT')
|
|
174
|
+
return { failure_class: 'probe_not_found' };
|
|
175
|
+
if (error.code === 'EACCES' || error.code === 'EPERM')
|
|
176
|
+
return { failure_class: 'probe_access_denied' };
|
|
177
|
+
// Node uses killed/signal for timeout as well as termination; do not claim which caused it.
|
|
178
|
+
if (error.killed === true || typeof error.signal === 'string')
|
|
179
|
+
return { failure_class: 'probe_terminated' };
|
|
180
|
+
if (typeof error.code === 'number' && Number.isSafeInteger(error.code)) {
|
|
181
|
+
return { failure_class: 'probe_exit', config_probe_exit_code: error.code };
|
|
109
182
|
}
|
|
183
|
+
return { failure_class: 'probe_error' };
|
|
110
184
|
}
|
|
111
185
|
//# sourceMappingURL=update-action.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable diagnostics for startup package updates.
|
|
3
|
+
*
|
|
4
|
+
* The update gate runs before the runtime's client logger exists (`shortcut app` exits before
|
|
5
|
+
* main starts), so every attempt leaves two local records: a debug-log section with sanitized
|
|
6
|
+
* installer output, and a bounded receipt that the next runtime start reports through the client
|
|
7
|
+
* logger. A receipt still `started` at that point means the attempt never recorded an outcome.
|
|
8
|
+
*/
|
|
9
|
+
import type { ShortcutClientLogger } from '../app/observability/client-logging/client-logger.js';
|
|
10
|
+
export type UpdateAttemptOutcome = 'verified' | 'failed' | 'not_installed' | 'shadowed' | 'unconfirmed' | 'error' | 'missing_terminal';
|
|
11
|
+
export interface UpdateFailureDiagnostics {
|
|
12
|
+
failure_stage?: 'npm_config' | 'installer';
|
|
13
|
+
/** Allowlisted classification; never a raw exception code or message. */
|
|
14
|
+
failure_class?: string;
|
|
15
|
+
/** Probe status is separate from the installer status: no installer ran on probe failure. */
|
|
16
|
+
config_probe_exit_code?: number;
|
|
17
|
+
}
|
|
18
|
+
/** Content-free record of one accepted update. Never holds paths, output or account data. */
|
|
19
|
+
export interface UpdateAttemptReceipt extends UpdateFailureDiagnostics {
|
|
20
|
+
attempt_id: string;
|
|
21
|
+
started_at: string;
|
|
22
|
+
from_version: string;
|
|
23
|
+
target_version: string;
|
|
24
|
+
package_manager: string;
|
|
25
|
+
/** Whether npm was bound to the running installation's prefix. */
|
|
26
|
+
prefix_bound: boolean;
|
|
27
|
+
phase: 'started' | 'finished';
|
|
28
|
+
outcome?: UpdateAttemptOutcome;
|
|
29
|
+
exit_code?: number | null;
|
|
30
|
+
signal?: string | null;
|
|
31
|
+
duration_ms?: number;
|
|
32
|
+
/** Version read back from the running installation's package.json after the install. */
|
|
33
|
+
installed_version?: string;
|
|
34
|
+
/** Version a fresh `shortcut` on PATH runs after the install. */
|
|
35
|
+
resolved_version?: string;
|
|
36
|
+
resolved_command_found?: boolean;
|
|
37
|
+
/** Whether that fresh `shortcut` runs the installation that was updated. */
|
|
38
|
+
resolved_matches_installation?: boolean;
|
|
39
|
+
/** `package`: read from the package the shim names; `probe`: from running it with --version. */
|
|
40
|
+
resolved_version_source?: 'package' | 'probe';
|
|
41
|
+
warning_count?: number;
|
|
42
|
+
output_truncated?: boolean;
|
|
43
|
+
reported?: boolean;
|
|
44
|
+
}
|
|
45
|
+
export declare function getUpdateAttemptsPath(): string;
|
|
46
|
+
/** Strip terminal escapes and credentials from installer output before it is stored or shown. */
|
|
47
|
+
export declare function sanitizeInstallerOutput(output: string): string;
|
|
48
|
+
/** Warnings to show in the terminal, and the distinct count kept for the receipt. */
|
|
49
|
+
export declare function boundedInstallerWarnings(sanitizedOutput: string): {
|
|
50
|
+
shown: string[];
|
|
51
|
+
count: number;
|
|
52
|
+
};
|
|
53
|
+
/** Bounded failure class from installer output; never raw text. */
|
|
54
|
+
export declare function classifyInstallerFailure(sanitizedOutput: string): string | undefined;
|
|
55
|
+
/** Append one update section, with sanitized installer output, to the CLI debug log. */
|
|
56
|
+
export declare function appendUpdateLog(section: {
|
|
57
|
+
title: string;
|
|
58
|
+
fields: Record<string, string | number | boolean | null | undefined>;
|
|
59
|
+
}, output: string | undefined, resolveLogPath?: () => string): string | undefined;
|
|
60
|
+
export interface UpdateAttemptRecorder {
|
|
61
|
+
readonly attemptId: string;
|
|
62
|
+
finish(result: Omit<UpdateAttemptReceipt, keyof StartedFields | 'phase' | 'reported'>): void;
|
|
63
|
+
}
|
|
64
|
+
type StartedFields = Pick<UpdateAttemptReceipt, 'attempt_id' | 'started_at' | 'from_version' | 'target_version' | 'package_manager' | 'prefix_bound'>;
|
|
65
|
+
/**
|
|
66
|
+
* Persist a `started` receipt before the installer runs, so an attempt killed mid-install is
|
|
67
|
+
* still visible to the next runtime start. Recording never blocks or fails the update.
|
|
68
|
+
*/
|
|
69
|
+
export declare function startUpdateAttempt(input: Omit<StartedFields, 'attempt_id' | 'started_at'>, options?: {
|
|
70
|
+
path?: string;
|
|
71
|
+
now?: () => Date;
|
|
72
|
+
}): UpdateAttemptRecorder;
|
|
73
|
+
/**
|
|
74
|
+
* Report unreported update receipts through the runtime logger. `running_version` is the version
|
|
75
|
+
* this start runs, which shows whether the previous attempt actually took effect. Receipts are
|
|
76
|
+
* marked reported only after the logger confirms delivery; a start that exits first (or a policy
|
|
77
|
+
* that keeps usage logs local) leaves them for the next start. Rows may repeat: join on
|
|
78
|
+
* `attempt_id`.
|
|
79
|
+
*/
|
|
80
|
+
export declare function reportPendingUpdateAttempts(logger: Pick<ShortcutClientLogger, 'info' | 'warn' | 'flush'>, options?: {
|
|
81
|
+
path?: string;
|
|
82
|
+
runningVersion?: string;
|
|
83
|
+
}): Promise<number>;
|
|
84
|
+
export {};
|
|
85
|
+
//# sourceMappingURL=update-diagnostics.d.ts.map
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable diagnostics for startup package updates.
|
|
3
|
+
*
|
|
4
|
+
* The update gate runs before the runtime's client logger exists (`shortcut app` exits before
|
|
5
|
+
* main starts), so every attempt leaves two local records: a debug-log section with sanitized
|
|
6
|
+
* installer output, and a bounded receipt that the next runtime start reports through the client
|
|
7
|
+
* logger. A receipt still `started` at that point means the attempt never recorded an outcome.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'crypto';
|
|
10
|
+
import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
11
|
+
import { dirname, join } from 'path';
|
|
12
|
+
import { getAgentDir, getDebugLogPath, VERSION } from '../config.js';
|
|
13
|
+
const MAX_RECEIPTS = 20;
|
|
14
|
+
const MAX_WARNING_LINES = 5;
|
|
15
|
+
const MAX_WARNING_LENGTH = 240;
|
|
16
|
+
export function getUpdateAttemptsPath() {
|
|
17
|
+
return join(getAgentDir(), 'update-attempts.json');
|
|
18
|
+
}
|
|
19
|
+
const REDACTIONS = [
|
|
20
|
+
// .npmrc-style credentials echoed by npm config errors.
|
|
21
|
+
[/(_authToken|_auth|_password|password|token)(\s*[=:]\s*)\S+/gi, '$1$2[redacted]'],
|
|
22
|
+
[/(authorization:\s*)(bearer|basic)?\s*\S+/gi, '$1[redacted]'],
|
|
23
|
+
[/\bbearer\s+[\w.~+/-]+=*/gi, 'Bearer [redacted]'],
|
|
24
|
+
// Credentials embedded in registry URLs.
|
|
25
|
+
[/(\b[a-z][\w+.-]*:\/\/)[^\s/@:]+(?::[^\s/@]*)?@/gi, '$1[redacted]@'],
|
|
26
|
+
// npm and GitHub token formats.
|
|
27
|
+
[/\bnpm_[A-Za-z0-9]{20,}\b/g, '[redacted]'],
|
|
28
|
+
[/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '[redacted]']
|
|
29
|
+
];
|
|
30
|
+
/** Strip terminal escapes and credentials from installer output before it is stored or shown. */
|
|
31
|
+
export function sanitizeInstallerOutput(output) {
|
|
32
|
+
let sanitized = output.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\r(?!\n)/g, '\n');
|
|
33
|
+
for (const [pattern, replacement] of REDACTIONS) {
|
|
34
|
+
sanitized = sanitized.replace(pattern, replacement);
|
|
35
|
+
}
|
|
36
|
+
return sanitized;
|
|
37
|
+
}
|
|
38
|
+
/** Warning lines npm, pnpm and yarn print, deduplicated. */
|
|
39
|
+
function installerWarnings(sanitizedOutput) {
|
|
40
|
+
const warnings = [];
|
|
41
|
+
for (const raw of sanitizedOutput.split('\n')) {
|
|
42
|
+
const line = raw.trim();
|
|
43
|
+
if (!/^(npm\s+warn(ing)?\b|warn\b|warning\b)/i.test(line))
|
|
44
|
+
continue;
|
|
45
|
+
const bounded = line.length > MAX_WARNING_LENGTH ? `${line.slice(0, MAX_WARNING_LENGTH)}…` : line;
|
|
46
|
+
if (!warnings.includes(bounded))
|
|
47
|
+
warnings.push(bounded);
|
|
48
|
+
}
|
|
49
|
+
return warnings;
|
|
50
|
+
}
|
|
51
|
+
/** Warnings to show in the terminal, and the distinct count kept for the receipt. */
|
|
52
|
+
export function boundedInstallerWarnings(sanitizedOutput) {
|
|
53
|
+
const warnings = installerWarnings(sanitizedOutput);
|
|
54
|
+
return { shown: warnings.slice(0, MAX_WARNING_LINES), count: warnings.length };
|
|
55
|
+
}
|
|
56
|
+
/** Bounded failure class from installer output; never raw text. */
|
|
57
|
+
export function classifyInstallerFailure(sanitizedOutput) {
|
|
58
|
+
const codes = ['ETARGET', 'E404', 'EEXIST', 'EPERM', 'EACCES', 'EBUSY', 'ENOTEMPTY', 'ENOENT'];
|
|
59
|
+
const code = codes.find((candidate) => new RegExp(`\\b${candidate}\\b`).test(sanitizedOutput));
|
|
60
|
+
if (code)
|
|
61
|
+
return code.toLowerCase();
|
|
62
|
+
if (/\b(E401|E403|ENEEDAUTH|EAUTH)\b/i.test(sanitizedOutput))
|
|
63
|
+
return 'authentication';
|
|
64
|
+
if (/\b(SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_VERIFY_LEAF_SIGNATURE|CERT_HAS_EXPIRED|UNABLE_TO_GET_ISSUER_CERT_LOCALLY|ERR_TLS_CERT_ALTNAME_INVALID)\b/i.test(sanitizedOutput))
|
|
65
|
+
return 'certificate';
|
|
66
|
+
if (/ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|network/i.test(sanitizedOutput))
|
|
67
|
+
return 'network';
|
|
68
|
+
if (/integrity|EINTEGRITY|TAR_BAD_ARCHIVE|unexpected end/i.test(sanitizedOutput))
|
|
69
|
+
return 'integrity';
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
/** Append one update section, with sanitized installer output, to the CLI debug log. */
|
|
73
|
+
export function appendUpdateLog(section, output, resolveLogPath = getDebugLogPath) {
|
|
74
|
+
try {
|
|
75
|
+
const logPath = resolveLogPath();
|
|
76
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
77
|
+
const lines = [
|
|
78
|
+
'',
|
|
79
|
+
`--- ${section.title} ---`,
|
|
80
|
+
`time=${new Date().toISOString()}`,
|
|
81
|
+
...Object.entries(section.fields)
|
|
82
|
+
.filter(([, value]) => value !== undefined)
|
|
83
|
+
.map(([key, value]) => `${key}=${String(value)}`),
|
|
84
|
+
...(output ? [sanitizeInstallerOutput(output)] : [])
|
|
85
|
+
];
|
|
86
|
+
appendFileSync(logPath, `${lines.join('\n')}\n`, 'utf-8');
|
|
87
|
+
return logPath;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function readReceipts(path) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
96
|
+
return Array.isArray(parsed)
|
|
97
|
+
? parsed.filter((entry) => typeof entry === 'object' &&
|
|
98
|
+
entry !== null &&
|
|
99
|
+
typeof entry.attempt_id === 'string')
|
|
100
|
+
: [];
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function writeReceipts(path, receipts) {
|
|
107
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
108
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
109
|
+
writeFileSync(temporary, `${JSON.stringify(receipts.slice(-MAX_RECEIPTS), null, 2)}\n`, 'utf-8');
|
|
110
|
+
renameSync(temporary, path);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Persist a `started` receipt before the installer runs, so an attempt killed mid-install is
|
|
114
|
+
* still visible to the next runtime start. Recording never blocks or fails the update.
|
|
115
|
+
*/
|
|
116
|
+
export function startUpdateAttempt(input, options = {}) {
|
|
117
|
+
const path = options.path ?? getUpdateAttemptsPath();
|
|
118
|
+
const now = options.now ?? (() => new Date());
|
|
119
|
+
const started = now();
|
|
120
|
+
const receipt = {
|
|
121
|
+
...input,
|
|
122
|
+
attempt_id: randomUUID(),
|
|
123
|
+
started_at: started.toISOString(),
|
|
124
|
+
phase: 'started'
|
|
125
|
+
};
|
|
126
|
+
const save = (next) => {
|
|
127
|
+
try {
|
|
128
|
+
const receipts = readReceipts(path).filter((entry) => entry.attempt_id !== next.attempt_id);
|
|
129
|
+
writeReceipts(path, [...receipts, next]);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Diagnostics are best effort; the terminal and debug log still report the result.
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
save(receipt);
|
|
136
|
+
return {
|
|
137
|
+
attemptId: receipt.attempt_id,
|
|
138
|
+
finish(result) {
|
|
139
|
+
save({
|
|
140
|
+
...receipt,
|
|
141
|
+
...result,
|
|
142
|
+
phase: 'finished',
|
|
143
|
+
duration_ms: result.duration_ms ?? now().getTime() - started.getTime()
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Report unreported update receipts through the runtime logger. `running_version` is the version
|
|
150
|
+
* this start runs, which shows whether the previous attempt actually took effect. Receipts are
|
|
151
|
+
* marked reported only after the logger confirms delivery; a start that exits first (or a policy
|
|
152
|
+
* that keeps usage logs local) leaves them for the next start. Rows may repeat: join on
|
|
153
|
+
* `attempt_id`.
|
|
154
|
+
*/
|
|
155
|
+
export async function reportPendingUpdateAttempts(logger, options = {}) {
|
|
156
|
+
const path = options.path ?? getUpdateAttemptsPath();
|
|
157
|
+
const pending = readReceipts(path).filter((receipt) => !receipt.reported);
|
|
158
|
+
if (pending.length === 0)
|
|
159
|
+
return 0;
|
|
160
|
+
const runningVersion = options.runningVersion ?? VERSION;
|
|
161
|
+
for (const receipt of pending) {
|
|
162
|
+
const { reported: _reported, phase, ...fields } = receipt;
|
|
163
|
+
const outcome = phase === 'started' ? 'missing_terminal' : (receipt.outcome ?? 'missing_terminal');
|
|
164
|
+
const emit = outcome === 'verified' ? logger.info : logger.warn;
|
|
165
|
+
emit.call(logger, {
|
|
166
|
+
module: 'lifecycle',
|
|
167
|
+
event: 'cli_update_attempt',
|
|
168
|
+
data: {
|
|
169
|
+
...fields,
|
|
170
|
+
outcome,
|
|
171
|
+
running_version: runningVersion,
|
|
172
|
+
running_target_version: runningVersion === receipt.target_version
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (!(await logger.flush()))
|
|
177
|
+
return 0;
|
|
178
|
+
const delivered = new Set(pending.map((receipt) => receipt.attempt_id));
|
|
179
|
+
try {
|
|
180
|
+
// Re-read so attempts recorded meanwhile are kept as they are.
|
|
181
|
+
writeReceipts(path, readReceipts(path).map((receipt) => delivered.has(receipt.attempt_id) ? { ...receipt, reported: true } : receipt));
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// An unwritable agent dir reports the same receipts again next start.
|
|
185
|
+
}
|
|
186
|
+
return pending.length;
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=update-diagnostics.js.map
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { type ForegroundUpdateResult, type UpdateLaunchRequest } from './update-action.js';
|
|
2
|
+
import { appendUpdateLog, startUpdateAttempt } from './update-diagnostics.js';
|
|
2
3
|
import type { AvailableUpdate } from './update-manager.js';
|
|
4
|
+
import { type UpdateVerification } from './update-verification.js';
|
|
3
5
|
interface StartupUpdateExecutionOptions {
|
|
4
6
|
runUpdate?: (request: UpdateLaunchRequest) => Promise<ForegroundUpdateResult>;
|
|
7
|
+
verify?: (update: AvailableUpdate) => Promise<UpdateVerification>;
|
|
8
|
+
startAttempt?: typeof startUpdateAttempt;
|
|
9
|
+
appendLog?: typeof appendUpdateLog;
|
|
10
|
+
platform?: NodeJS.Platform;
|
|
5
11
|
log?: (message: string) => void;
|
|
6
12
|
error?: (message: string) => void;
|
|
7
13
|
}
|
|
8
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Execute an accepted package update and print the shared terminal result.
|
|
16
|
+
*
|
|
17
|
+
* Exit code 0 is not enough (#14571): success is reported only after the installed package and
|
|
18
|
+
* the `shortcut` each shell runs next read back as the advertised release; otherwise the user
|
|
19
|
+
* gets the mismatch and how to recover, and the update exits 1.
|
|
20
|
+
*/
|
|
9
21
|
export declare function executeStartupUpdate(update: AvailableUpdate, options?: StartupUpdateExecutionOptions): Promise<ForegroundUpdateResult>;
|
|
10
22
|
export {};
|
|
11
23
|
//# sourceMappingURL=update-execution.d.ts.map
|