fraim-hub 2.0.274 → 2.0.276

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.
@@ -379,6 +379,27 @@ function readJobFrontmatter(filePath) {
379
379
  return null;
380
380
  }
381
381
  }
382
+ /**
383
+ * Issue #1246: resolve the stub whose phase graph actually governs this job.
384
+ * A personalized override that is `extends`-only (no `phases`/`initialPhase`
385
+ * of its own) wins the layer-precedence override chain in findJobStubPath,
386
+ * but declares no phase graph AND has no `## Steps` section for
387
+ * loadJobPhasesFromSteps to fall back to either — every phase-derivation
388
+ * caller silently degraded to zero phases. Follow `extends` to the baseline
389
+ * stub when the resolved stub itself declares no phase graph.
390
+ */
391
+ function resolveDeclaredPhaseStub(stubPath, projectPath) {
392
+ const fm = readJobFrontmatter(stubPath);
393
+ if (fm?.initialPhase && fm.phases)
394
+ return { fm, path: stubPath };
395
+ const extendsValue = typeof fm?.extends === 'string' ? fm.extends.trim() : '';
396
+ if (!extendsValue)
397
+ return { fm, path: stubPath };
398
+ const basePath = resolveExtendedStubPath(projectPath, extendsValue);
399
+ if (!basePath)
400
+ return { fm, path: stubPath };
401
+ return { fm: readJobFrontmatter(basePath), path: basePath };
402
+ }
382
403
  function findJobStubPath(projectPath, jobId) {
383
404
  // Walk both employee and manager layer sets. The analytics surfaces can
384
405
  // inspect any FRAIM job run, including manager-side jobs such as
@@ -432,9 +453,9 @@ function loadJobPhases(jobId, projectPath, discriminant = 'feature') {
432
453
  const stubPath = findJobStubPath(projectPath, jobId);
433
454
  if (!stubPath)
434
455
  return [];
435
- const fm = readJobFrontmatter(stubPath);
456
+ const { fm, path: declaredPath } = resolveDeclaredPhaseStub(stubPath, projectPath);
436
457
  if (!fm || !fm.initialPhase || !fm.phases)
437
- return loadJobPhasesFromSteps(stubPath);
458
+ return loadJobPhasesFromSteps(declaredPath);
438
459
  const visited = new Set();
439
460
  const ordered = [];
440
461
  let cursor = fm.initialPhase;
@@ -461,7 +482,7 @@ function resolveJobPhaseTransition(jobId, projectPath, phaseId, outcome, discrim
461
482
  const stubPath = findJobStubPath(projectPath, jobId);
462
483
  if (!stubPath)
463
484
  return null;
464
- const fm = readJobFrontmatter(stubPath);
485
+ const { fm } = resolveDeclaredPhaseStub(stubPath, projectPath);
465
486
  if (!fm || !fm.phases)
466
487
  return null;
467
488
  const phaseDef = fm.phases[phaseId];
@@ -474,9 +495,9 @@ function loadAllJobPhaseIds(jobId, projectPath) {
474
495
  const stubPath = findJobStubPath(projectPath, jobId);
475
496
  if (!stubPath)
476
497
  return new Set();
477
- const fm = readJobFrontmatter(stubPath);
498
+ const { fm, path: declaredPath } = resolveDeclaredPhaseStub(stubPath, projectPath);
478
499
  if (!fm || !fm.phases) {
479
- const phases = loadJobPhasesFromSteps(stubPath);
500
+ const phases = loadJobPhasesFromSteps(declaredPath);
480
501
  return new Set(phases.map((p) => p.id));
481
502
  }
482
503
  return new Set(Object.keys(fm.phases));
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseConfiguredCommand = parseConfiguredCommand;
7
+ exports.describeConfiguredCommandInvocationProblem = describeConfiguredCommandInvocationProblem;
8
+ exports.commandContainsLiteralSecret = commandContainsLiteralSecret;
9
+ exports.decorateHostPlanWithConfiguredAgent = decorateHostPlanWithConfiguredAgent;
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
13
+ const BASH_ENV_PREFIX = /^([A-Za-z_][A-Za-z0-9_]*)=(?:\$\([^)]*\)|"[^"]*"|'[^']*'|\S*)\s+/;
14
+ // The value may be a parenthesized PowerShell expression followed by a
15
+ // property-access chain (e.g. `(Get-Content ... | ConvertFrom-Json).api_key`),
16
+ // a quoted string, or a bare token — always terminated by the statement `;`.
17
+ const POWERSHELL_ENV_PREFIX = /^\$env:[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:\([^)]*\)(?:\.[A-Za-z_][A-Za-z0-9_]*)*|"[^"]*"|'[^']*'|\S+?)\s*;\s*/;
18
+ function parseConfiguredCommand(runner, commandText) {
19
+ let rest = String(commandText || '').trim();
20
+ const prefixPattern = runner === 'powershell' ? POWERSHELL_ENV_PREFIX : BASH_ENV_PREFIX;
21
+ let prefixEnd = 0;
22
+ while (true) {
23
+ const match = rest.slice(prefixEnd).match(prefixPattern);
24
+ if (!match)
25
+ break;
26
+ prefixEnd += match[0].length;
27
+ }
28
+ const envPrefixText = rest.slice(0, prefixEnd).trim();
29
+ const invocation = rest.slice(prefixEnd).trim();
30
+ const tokens = tokenizeShellLike(invocation);
31
+ return { envPrefixText, extraArgs: tokens.slice(1), invocationHead: tokens[0] || '' };
32
+ }
33
+ // Reviewer finding (PR #1209, Round 3): parseConfiguredCommand's leniency is
34
+ // intentional for *parsing* (it never throws), but that leniency means a
35
+ // structurally wrong command — most dangerously a shell built-in like `.`
36
+ // (source) or `source` at the head, meant to load a setup script into the
37
+ // current shell — parses "successfully" into a garbage invocationHead that
38
+ // decorateHostPlanWithConfiguredAgent then silently discards, appending only
39
+ // the path/args that followed it as bogus positional arguments to the real
40
+ // agent binary instead of ever sourcing the script. Since the invocation
41
+ // head is always discarded and replaced with Hub's own known base binary,
42
+ // the only way to catch this class of error is to check, at readiness time,
43
+ // that what the user typed there actually was that same base binary — not
44
+ // to attempt to execute or otherwise interpret it.
45
+ function describeConfiguredCommandInvocationProblem(parsed, baseHostId) {
46
+ if (!parsed.invocationHead) {
47
+ return 'Command has no invocation to run after the environment prefix.';
48
+ }
49
+ if (parsed.invocationHead !== baseHostId) {
50
+ return `Command must invoke "${baseHostId}" (found "${parsed.invocationHead}"). A setup script should be registered as a setup script, not composed into this field — see hub-configured-agent-setup.md.`;
51
+ }
52
+ return null;
53
+ }
54
+ function tokenizeShellLike(text) {
55
+ const tokens = [];
56
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
57
+ let match;
58
+ while ((match = re.exec(text)) !== null) {
59
+ tokens.push(match[1] ?? match[2] ?? match[3]);
60
+ }
61
+ return tokens;
62
+ }
63
+ // Issue #1201 (R2.3): UX safety net only — flags what pattern-matches as a
64
+ // literal high-entropy secret pasted into the command field. Not a substitute
65
+ // for the routing skill never generating such a command in the first place.
66
+ const LITERAL_SECRET_TOKEN = /^[A-Za-z0-9+/_.=-]{20,}$/;
67
+ function commandContainsLiteralSecret(commandText) {
68
+ for (const token of String(commandText || '').split(/\s+/)) {
69
+ const eq = token.indexOf('=');
70
+ if (eq <= 0)
71
+ continue;
72
+ const value = token.slice(eq + 1);
73
+ if (!value || value.startsWith('$') || value.startsWith('`') || value.startsWith('('))
74
+ continue;
75
+ if (LITERAL_SECRET_TOKEN.test(value))
76
+ return true;
77
+ }
78
+ return false;
79
+ }
80
+ // The env-prefix assignment is evaluated as a bash "temporary environment"
81
+ // directly attached to the `exec` command on the same line (`VAR=$(...) exec
82
+ // "$@"`), not as a separate statement before it. A bare `VAR=value` on its
83
+ // own line only sets a local shell variable — it is NOT exported, so it
84
+ // would silently vanish from the real binary's environment once `exec`
85
+ // replaces this process image. Attaching it to the same command line is
86
+ // exactly what a terminal user's `VAR=$(...) codex ...` already does.
87
+ const BASH_LAUNCH_WRAPPER = [
88
+ '#!/usr/bin/env bash',
89
+ 'set -e',
90
+ 'if [ -n "$FRAIM_CONFIGURED_AGENT_ENV_PREFIX" ]; then',
91
+ ' eval "$FRAIM_CONFIGURED_AGENT_ENV_PREFIX exec \\"\\$@\\""',
92
+ 'else',
93
+ ' exec "$@"',
94
+ 'fi',
95
+ '',
96
+ ].join('\n');
97
+ const POWERSHELL_LAUNCH_WRAPPER = [
98
+ '$ErrorActionPreference = "Stop"',
99
+ 'if ($env:FRAIM_CONFIGURED_AGENT_ENV_PREFIX) {',
100
+ ' Invoke-Expression $env:FRAIM_CONFIGURED_AGENT_ENV_PREFIX',
101
+ '}',
102
+ '$exe = $args[0]',
103
+ 'if ($args.Length -gt 1) { $rest = $args[1..($args.Length - 1)] } else { $rest = @() }',
104
+ '& $exe @rest',
105
+ 'exit $LASTEXITCODE',
106
+ '',
107
+ ].join('\r\n');
108
+ // Writes the small, fixed wrapper script that lets a configured-agent's
109
+ // env-prefix (which may contain `$(...)`/`(...)` substitutions) resolve in a
110
+ // real shell at spawn time, then `exec`s Hub's own required invocation with
111
+ // the parsed extra CLI args appended. Content-checked so it self-heals like
112
+ // any other FRAIM-generated artifact (issue #1206 precedent) rather than
113
+ // silently going stale if regenerated with different logic.
114
+ //
115
+ // Security review (issue #1201): written under the per-user FRAIM home
116
+ // (`getUserFraimDirPath()`), NOT `os.tmpdir()`. A predictable filename in a
117
+ // shared, world-writable OS temp directory is a symlink-race target on
118
+ // multi-user machines — another local user could pre-place a symlink at the
119
+ // predictable path, and `fs.writeFileSync` would follow it and overwrite an
120
+ // arbitrary file the *victim* user can write to. The per-user FRAIM home is
121
+ // not shared across OS accounts, closing that class of attack.
122
+ function ensureConfiguredAgentLaunchWrapper(runner) {
123
+ const content = runner === 'bash' ? BASH_LAUNCH_WRAPPER : POWERSHELL_LAUNCH_WRAPPER;
124
+ const filePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), runner === 'bash' ? 'hub-configured-agent-launch.sh' : 'hub-configured-agent-launch.ps1');
125
+ const existing = fs_1.default.existsSync(filePath) ? fs_1.default.readFileSync(filePath, 'utf8') : null;
126
+ if (existing !== content) {
127
+ fs_1.default.writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o755 });
128
+ }
129
+ return filePath;
130
+ }
131
+ function decorateHostPlanWithConfiguredAgent(plan, agent, agentEnv = {}) {
132
+ if (!agent)
133
+ return plan;
134
+ const withEnv = { ...plan, env: { ...(plan.env || {}), ...agentEnv } };
135
+ if (!agent.command)
136
+ return withEnv;
137
+ const parsed = parseConfiguredCommand(agent.command.runner, agent.command.command);
138
+ if (!parsed.envPrefixText && parsed.extraArgs.length === 0)
139
+ return withEnv;
140
+ const wrapperPath = ensureConfiguredAgentLaunchWrapper(agent.command.runner);
141
+ const realInvocation = [plan.command, ...plan.args, ...parsed.extraArgs];
142
+ const decoratedEnv = { ...withEnv.env, FRAIM_CONFIGURED_AGENT_ENV_PREFIX: parsed.envPrefixText };
143
+ if (agent.command.runner === 'bash') {
144
+ return { ...withEnv, command: 'bash', args: [wrapperPath, ...realInvocation], env: decoratedEnv };
145
+ }
146
+ return {
147
+ ...withEnv,
148
+ command: 'powershell.exe',
149
+ args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', wrapperPath, ...realInvocation],
150
+ env: decoratedEnv,
151
+ };
152
+ }
@@ -3,18 +3,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.AiHubConfiguredAgentStore = void 0;
6
+ exports.describeConfiguredCommandInvocationProblem = exports.decorateHostPlanWithConfiguredAgent = exports.commandContainsLiteralSecret = exports.parseConfiguredCommand = exports.AiHubConfiguredAgentStore = void 0;
7
7
  exports.synthesizeDefaultConfiguredAgents = synthesizeDefaultConfiguredAgents;
8
8
  exports.checkConfiguredAgentAvailability = checkConfiguredAgentAvailability;
9
9
  exports.checkConfiguredAgentReadiness = checkConfiguredAgentReadiness;
10
10
  exports.projectConfiguredAgent = projectConfiguredAgent;
11
11
  exports.resolveConfiguredAgentForHost = resolveConfiguredAgentForHost;
12
12
  exports.resolveConfiguredAgentEnv = resolveConfiguredAgentEnv;
13
- exports.decorateHostPlanWithConfiguredAgent = decorateHostPlanWithConfiguredAgent;
14
13
  const fs_1 = __importDefault(require("fs"));
15
14
  const path_1 = __importDefault(require("path"));
16
15
  const child_process_1 = require("child_process");
17
16
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
17
+ const configured_agent_command_1 = require("./configured-agent-command");
18
18
  const VALID_HOSTS = ['codex', 'claude', 'gemini', 'copilot'];
19
19
  const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
20
20
  function nowIso() {
@@ -48,6 +48,9 @@ function normalizeAgent(input) {
48
48
  const setupScript = normalizeSetupScript(input.setupScript);
49
49
  if (input.setupScript && !setupScript)
50
50
  return { ok: false, error: 'Setup script command is required.' };
51
+ const command = normalizeCommand(input.command);
52
+ if (input.command && !command)
53
+ return { ok: false, error: 'Command is required.' };
51
54
  const createdAt = input.createdAt || nowIso();
52
55
  return {
53
56
  ok: true,
@@ -57,6 +60,7 @@ function normalizeAgent(input) {
57
60
  ...(input.description && String(input.description).trim() ? { description: String(input.description).trim() } : {}),
58
61
  baseHostId,
59
62
  enabled: input.enabled !== false,
63
+ ...(command ? { command } : {}),
60
64
  ...(setupScript ? { setupScript } : {}),
61
65
  createdAt,
62
66
  updatedAt: nowIso(),
@@ -77,6 +81,24 @@ function normalizeSetupScript(value) {
77
81
  timeoutMs: typeof value.timeoutMs === 'number' && value.timeoutMs > 0 ? Math.min(value.timeoutMs, 120_000) : 30_000,
78
82
  };
79
83
  }
84
+ // Issue #1201 (R2.1): the single-text-field setup surface. `runner` is an
85
+ // internal execution detail — callers that omit it get the current platform's
86
+ // default shell so the UI never has to ask the user to pick one.
87
+ function normalizeCommand(value) {
88
+ if (!value)
89
+ return null;
90
+ const command = String(value.command || '').trim();
91
+ if (!command)
92
+ return null;
93
+ const runner = value.runner === 'bash' || value.runner === 'powershell'
94
+ ? value.runner
95
+ : (process.platform === 'win32' ? 'powershell' : 'bash');
96
+ return {
97
+ runner,
98
+ command,
99
+ timeoutMs: typeof value.timeoutMs === 'number' && value.timeoutMs > 0 ? Math.min(value.timeoutMs, 120_000) : 30_000,
100
+ };
101
+ }
80
102
  function synthesizeDefaultConfiguredAgents(employees) {
81
103
  const timestamp = '1970-01-01T00:00:00.000Z';
82
104
  return employees
@@ -152,7 +174,12 @@ function checkConfiguredAgentAvailability(agent, employees, env = process.env) {
152
174
  reasons.push('Configured agent is disabled.');
153
175
  if (!employee?.available)
154
176
  reasons.push(`${employee?.label || agent.baseHostId} is not available on this machine.`);
155
- if (agent.setupScript) {
177
+ if (agent.command) {
178
+ if (!agent.command.command.trim()) {
179
+ reasons.push('Command is empty.');
180
+ }
181
+ }
182
+ else if (agent.setupScript) {
156
183
  const command = agent.setupScript.command;
157
184
  const isPathLike = /[\\/]/.test(command) || /^[A-Za-z]:[\\/]/.test(command);
158
185
  if (isPathLike && !fs_1.default.existsSync(command)) {
@@ -160,13 +187,35 @@ function checkConfiguredAgentAvailability(agent, employees, env = process.env) {
160
187
  }
161
188
  }
162
189
  else if (!agent.id.endsWith('-default')) {
163
- warnings.push('No setup script configured; this profile launches the base CLI unchanged.');
190
+ warnings.push('No command configured; this profile launches the base CLI unchanged.');
164
191
  }
165
192
  return { id: agent.id, label: agent.label, baseHostId: agent.baseHostId, enabled: agent.enabled, available: reasons.length === 0, reasons, warnings };
166
193
  }
167
194
  function checkConfiguredAgentReadiness(agent, employees, env = process.env) {
168
195
  const check = checkConfiguredAgentAvailability(agent, employees, env);
169
- if (!check.available || !agent.setupScript)
196
+ if (!check.available)
197
+ return check;
198
+ if (agent.command) {
199
+ try {
200
+ const parsed = (0, configured_agent_command_1.parseConfiguredCommand)(agent.command.runner, agent.command.command);
201
+ const problem = (0, configured_agent_command_1.describeConfiguredCommandInvocationProblem)(parsed, agent.baseHostId);
202
+ if (problem) {
203
+ return { ...check, available: false, reasons: [...check.reasons, problem] };
204
+ }
205
+ return check;
206
+ }
207
+ catch (error) {
208
+ return {
209
+ ...check,
210
+ available: false,
211
+ reasons: [
212
+ ...check.reasons,
213
+ error instanceof Error ? error.message : 'Configured agent command could not be parsed.',
214
+ ],
215
+ };
216
+ }
217
+ }
218
+ if (!agent.setupScript)
170
219
  return check;
171
220
  try {
172
221
  resolveConfiguredAgentEnv(agent);
@@ -191,6 +240,7 @@ function projectConfiguredAgent(agent, employees) {
191
240
  ...(agent.description ? { description: agent.description } : {}),
192
241
  baseHostId: agent.baseHostId,
193
242
  enabled: agent.enabled,
243
+ ...(agent.command ? { command: { ...agent.command } } : {}),
194
244
  ...(agent.setupScript ? { setupScript: { ...agent.setupScript, args: [...(agent.setupScript.args || [])] } } : {}),
195
245
  createdAt: agent.createdAt,
196
246
  updatedAt: agent.updatedAt,
@@ -295,11 +345,13 @@ function filterSafeEnv(env) {
295
345
  }
296
346
  return safe;
297
347
  }
298
- function decorateHostPlanWithConfiguredAgent(plan, agent, agentEnv = {}) {
299
- if (!agent)
300
- return plan;
301
- return {
302
- ...plan,
303
- env: { ...(plan.env || {}), ...agentEnv },
304
- };
305
- }
348
+ // Issue #1201: command parsing, secret detection, and launch decoration for
349
+ // the single-command configured-agent surface live in their own module
350
+ // (kept a cohesive, separately-testable unit rather than growing this file
351
+ // past the 500-line quality threshold) — re-exported here so existing
352
+ // callers importing from `./configured-agents` are unaffected.
353
+ var configured_agent_command_2 = require("./configured-agent-command");
354
+ Object.defineProperty(exports, "parseConfiguredCommand", { enumerable: true, get: function () { return configured_agent_command_2.parseConfiguredCommand; } });
355
+ Object.defineProperty(exports, "commandContainsLiteralSecret", { enumerable: true, get: function () { return configured_agent_command_2.commandContainsLiteralSecret; } });
356
+ Object.defineProperty(exports, "decorateHostPlanWithConfiguredAgent", { enumerable: true, get: function () { return configured_agent_command_2.decorateHostPlanWithConfiguredAgent; } });
357
+ Object.defineProperty(exports, "describeConfiguredCommandInvocationProblem", { enumerable: true, get: function () { return configured_agent_command_2.describeConfiguredCommandInvocationProblem; } });
@@ -107,7 +107,7 @@ function configureAutoUpdater() {
107
107
  function ensureWordSideload(projectPath, httpsPort) {
108
108
  // Flag version bump: bump this string when new manifests are added so all
109
109
  // users get re-sideloaded on their next launch.
110
- const FLAG_VERSION = 'v5-https-port';
110
+ const FLAG_VERSION = 'v6-excel-addin';
111
111
  const expectedFlag = `${FLAG_VERSION}:${httpsPort}`;
112
112
  const flagPath = path_1.default.join(electron_1.app.getPath('userData'), 'word-sideloaded.flag');
113
113
  const existingFlag = fs_1.default.existsSync(flagPath) ? fs_1.default.readFileSync(flagPath, 'utf8') : null;
@@ -4,28 +4,57 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getLatestPublishedVersion = getLatestPublishedVersion;
7
+ exports.getCachedLatestPublishedVersion = getCachedLatestPublishedVersion;
8
+ exports.refreshLatestPublishedVersionInBackground = refreshLatestPublishedVersionInBackground;
7
9
  exports.__resetLatestVersionCache = __resetLatestVersionCache;
8
10
  const https_1 = __importDefault(require("https"));
9
11
  // Issue #755: best-effort "latest published fraim version" for the Hub's
10
12
  // update-available nudge. Cached in-process (1h TTL) and never throws — returns
11
13
  // null when offline or the registry is unreachable, so the nudge simply hides.
12
14
  const TTL_MS = 60 * 60 * 1000;
15
+ const FAILED_RETRY_MS = 5 * 60 * 1000;
13
16
  const REGISTRY_URL = 'https://registry.npmjs.org/fraim/latest';
14
17
  let cache = { value: null, at: 0 };
18
+ let lastAttemptAt = 0;
19
+ let inFlight = null;
15
20
  async function getLatestPublishedVersion() {
16
21
  const now = Date.now();
17
22
  if (cache.at !== 0 && now - cache.at < TTL_MS)
18
23
  return cache.value;
19
- const value = await fetchNpmLatest();
20
- // Only cache successful lookups; a failed fetch retries next call rather than
21
- // caching a null for an hour.
22
- if (value !== null)
23
- cache = { value, at: now };
24
- return value ?? cache.value;
24
+ return refreshLatestPublishedVersion();
25
+ }
26
+ function getCachedLatestPublishedVersion() {
27
+ return cache.value;
28
+ }
29
+ function refreshLatestPublishedVersionInBackground() {
30
+ void refreshLatestPublishedVersion();
31
+ }
32
+ async function refreshLatestPublishedVersion() {
33
+ const now = Date.now();
34
+ if (cache.at !== 0 && now - cache.at < TTL_MS)
35
+ return cache.value;
36
+ if (inFlight)
37
+ return inFlight;
38
+ if (cache.value === null && lastAttemptAt !== 0 && now - lastAttemptAt < FAILED_RETRY_MS) {
39
+ return null;
40
+ }
41
+ lastAttemptAt = now;
42
+ inFlight = fetchNpmLatest()
43
+ .then((value) => {
44
+ if (value !== null)
45
+ cache = { value, at: Date.now() };
46
+ return value ?? cache.value;
47
+ })
48
+ .finally(() => {
49
+ inFlight = null;
50
+ });
51
+ return inFlight;
25
52
  }
26
53
  /** Test-only: reset the module cache. */
27
54
  function __resetLatestVersionCache() {
28
55
  cache = { value: null, at: 0 };
56
+ lastAttemptAt = 0;
57
+ inFlight = null;
29
58
  }
30
59
  function fetchNpmLatest() {
31
60
  return new Promise((resolve) => {
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
- * Sideloads Office add-in manifests (Word + PowerPoint) so they appear under
4
- * Insert > My Add-ins > Developer Add-ins without admin rights or AppSource.
3
+ * Sideloads Office add-in manifests (Word + PowerPoint + Excel) so they appear
4
+ * under Insert > My Add-ins > Developer Add-ins without admin rights or AppSource.
5
5
  *
6
6
  * Windows: writes HKCU\SOFTWARE\Microsoft\Office\16.0\WEF\Developer\{<guid>}
7
7
  * (Default) = ABSOLUTE FILE PATH to manifest.xml. This is the registry
@@ -52,6 +52,15 @@ const MANIFESTS = [
52
52
  ],
53
53
  macContainer: 'com.microsoft.Powerpoint',
54
54
  },
55
+ {
56
+ guid: 'da8d22c7-2c00-485a-bdc9-13dda7f80c48',
57
+ candidates: (base) => [
58
+ path_1.default.resolve(base, 'public/ai-hub/excel-taskpane/manifest.xml'),
59
+ path_1.default.resolve(__dirname, '..', '..', 'public/ai-hub/excel-taskpane/manifest.xml'),
60
+ path_1.default.resolve(__dirname, '..', '..', '..', 'public/ai-hub/excel-taskpane/manifest.xml'),
61
+ ],
62
+ macContainer: 'com.microsoft.Excel',
63
+ },
55
64
  ];
56
65
  function resolveManifestPath(entry, projectPath) {
57
66
  return entry.candidates(projectPath).find(c => fs_1.default.existsSync(c)) ?? null;
@@ -157,7 +166,7 @@ function sideloadManifest(projectPath, options = {}) {
157
166
  for (const entry of MANIFESTS) {
158
167
  const manifestPath = resolveManifestPath(entry, projectPath);
159
168
  if (!manifestPath) {
160
- return { ok: false, reason: `Manifest not found for GUID ${entry.guid} — check extensions/office-word/ and public/ai-hub/powerpoint-taskpane/` };
169
+ return { ok: false, reason: `Manifest not found for GUID ${entry.guid} — check extensions/office-word/, public/ai-hub/powerpoint-taskpane/, and public/ai-hub/excel-taskpane/` };
161
170
  }
162
171
  const sideloadPath = prepareManifestForSideload(entry, manifestPath, options);
163
172
  if (process.platform === 'win32') {