@vodmal/vdx-cli 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/package.json +1 -1
- package/rubric/vdx-rubric.yaml +1 -1
- package/src/ai.ts +128 -2
- package/src/doctor.ts +1 -1
- package/src/index.ts +20 -8
- package/src/init.ts +1 -1
- package/src/manifest.ts +2 -0
- package/src/publish.ts +66 -6
package/README.md
CHANGED
|
@@ -48,7 +48,10 @@ vdx publish <patch|minor|major> [--dry-run] [--force]
|
|
|
48
48
|
vdx doctor [--format=ansi|markdown|json]
|
|
49
49
|
|
|
50
50
|
# Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
|
|
51
|
-
vdx ai [project-path] [--new] [--restart] [--detach] [--dry-run] # default: cwd
|
|
51
|
+
vdx ai[@host] [project-path] [--new] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
|
|
52
|
+
|
|
53
|
+
# Print the installed version
|
|
54
|
+
vdx --version
|
|
52
55
|
|
|
53
56
|
# MCP stdio server consumed by the Claude Code plugin
|
|
54
57
|
vdx-mcp --project <path>
|
|
@@ -70,11 +73,19 @@ vdx publish minor # bump → npm publish (OTP prompt) → git commit + tag (no
|
|
|
70
73
|
# 4. Start the agent here, continuing its last conversation (--new: a new one);
|
|
71
74
|
# re-run to attach, --restart to apply a changed profile
|
|
72
75
|
vdx ai
|
|
76
|
+
|
|
77
|
+
# 5. The same on another machine (an ssh host with vdx), attaching from here
|
|
78
|
+
vdx ai@m3
|
|
73
79
|
```
|
|
74
80
|
|
|
75
81
|
`vdx ai` reads a personal profile, never a bundled one: `$VDX_ENVIRONMENT`, else
|
|
76
82
|
`~/.vdx-environment.yaml`, else a plain `claude`/`codex` with no flags. Format:
|
|
77
83
|
[docs/specs/environment-format.md](../docs/specs/environment-format.md).
|
|
84
|
+
The session's `{project}` is `[vdx] name` from the project's `mise.toml`, else the
|
|
85
|
+
first file in the profile's `session.project_names` that has one, else the repo
|
|
86
|
+
name. `vdx ai@<host>` runs the same command on `<host>` over ssh — that machine's vdx,
|
|
87
|
+
profile and tmux, the path taken under its home directory; `--detach` only
|
|
88
|
+
starts it. The machine's own label (`$VDX_HOST`) runs here.
|
|
78
89
|
|
|
79
90
|
By default the bundled `rubric/vdx-rubric.yaml` is used (a mirror of canonical
|
|
80
91
|
[vdx-rubric-vodmal](https://github.com/VoDmAl/vdx-rubric-vodmal) at the time of
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "vdx — unified lifecycle interface (up/down/build/test/check/fix) + versioned maturity audit with drift detection. Pairs with the vdx Claude Code plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vdx",
|
package/rubric/vdx-rubric.yaml
CHANGED
package/src/ai.ts
CHANGED
|
@@ -4,7 +4,8 @@ import * as path from 'node:path';
|
|
|
4
4
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
5
5
|
import YAML from 'js-yaml';
|
|
6
6
|
import { evalPredicate } from './evaluator.ts';
|
|
7
|
-
import { autoDetectStack, type Ctx } from './facts.ts';
|
|
7
|
+
import { autoDetectStack, readStructured, resolveConfigPath, type Ctx } from './facts.ts';
|
|
8
|
+
import { loadManifest } from './manifest.ts';
|
|
8
9
|
import type { Predicate } from './rubric.ts';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -46,9 +47,18 @@ export interface AgentProfile {
|
|
|
46
47
|
|
|
47
48
|
export type Multiplexer = 'tmux' | 'none';
|
|
48
49
|
|
|
50
|
+
/** A place a project's short name may come from: a structured file and a dot path into it. */
|
|
51
|
+
export interface NameSource {
|
|
52
|
+
/** `{repo}` is the repository name; `~` the home directory; relative to the profile's directory. */
|
|
53
|
+
file: string;
|
|
54
|
+
jsonpath: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
export interface SessionProfile {
|
|
50
58
|
multiplexer?: Multiplexer;
|
|
51
59
|
name?: string;
|
|
60
|
+
/** Tried after `[vdx] name` in the project's mise.toml; the first non-empty string wins. */
|
|
61
|
+
project_names?: NameSource[];
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
export interface Environment {
|
|
@@ -137,6 +147,19 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
137
147
|
if (session.name !== undefined && typeof session.name !== 'string') {
|
|
138
148
|
fail('session.name', 'must be a string');
|
|
139
149
|
}
|
|
150
|
+
if (session.project_names !== undefined) {
|
|
151
|
+
if (!Array.isArray(session.project_names)) fail('session.project_names', 'must be a list');
|
|
152
|
+
session.project_names.forEach((src, i) => {
|
|
153
|
+
const at = `session.project_names[${i}]`;
|
|
154
|
+
if (typeof src !== 'object' || src === null) fail(at, 'must be a mapping');
|
|
155
|
+
if (typeof src.file !== 'string' || src.file === '') fail(`${at}.file`, 'must be a non-empty string');
|
|
156
|
+
const unknown = src.file.match(/\{(?!repo\})\w+\}/);
|
|
157
|
+
if (unknown) fail(`${at}.file`, `has an unknown placeholder ${unknown[0]} (only {repo})`);
|
|
158
|
+
if (typeof src.jsonpath !== 'string' || src.jsonpath === '') {
|
|
159
|
+
fail(`${at}.jsonpath`, 'must be a non-empty string (a dot path, e.g. names.0)');
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
140
163
|
}
|
|
141
164
|
return envDoc;
|
|
142
165
|
}
|
|
@@ -172,6 +195,8 @@ export function renderSessionName(template: string, vars: Record<string, string>
|
|
|
172
195
|
export interface LaunchPlan {
|
|
173
196
|
projectRoot: string;
|
|
174
197
|
project: string;
|
|
198
|
+
/** Where `project` came from: mise.toml, a profile source, or the repository name. */
|
|
199
|
+
projectSource: string;
|
|
175
200
|
host: string;
|
|
176
201
|
profilePath: string | null;
|
|
177
202
|
command: string;
|
|
@@ -190,6 +215,7 @@ export function planLaunch(input: {
|
|
|
190
215
|
agent: AgentProfile;
|
|
191
216
|
projectRoot: string;
|
|
192
217
|
project: string;
|
|
218
|
+
projectSource?: string;
|
|
193
219
|
host: string;
|
|
194
220
|
}): LaunchPlan {
|
|
195
221
|
const { environment, agent, projectRoot } = input;
|
|
@@ -206,6 +232,7 @@ export function planLaunch(input: {
|
|
|
206
232
|
return {
|
|
207
233
|
projectRoot,
|
|
208
234
|
project: input.project,
|
|
235
|
+
projectSource: input.projectSource ?? 'repository name',
|
|
209
236
|
host: input.host,
|
|
210
237
|
profilePath: input.profilePath,
|
|
211
238
|
command: agent.command,
|
|
@@ -384,6 +411,40 @@ export function projectIdentity(projectRoot: string): string {
|
|
|
384
411
|
return path.basename(projectRoot);
|
|
385
412
|
}
|
|
386
413
|
|
|
414
|
+
export interface ProjectName {
|
|
415
|
+
name: string;
|
|
416
|
+
source: string;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* `{project}`: `[vdx] name` in the project's mise.toml, else the first profile
|
|
421
|
+
* source holding a non-empty string, else the repository name. A source that is
|
|
422
|
+
* missing or unreadable is skipped — a name is a label, not a reason to fail.
|
|
423
|
+
*/
|
|
424
|
+
export function resolveProjectName(input: {
|
|
425
|
+
projectRoot: string;
|
|
426
|
+
repo: string;
|
|
427
|
+
sources: NameSource[];
|
|
428
|
+
home: string;
|
|
429
|
+
profileDir: string;
|
|
430
|
+
}): ProjectName {
|
|
431
|
+
const own = loadManifest(input.projectRoot)?.name;
|
|
432
|
+
if (typeof own === 'string' && own.trim()) return { name: own.trim(), source: 'mise.toml [vdx] name' };
|
|
433
|
+
for (const src of input.sources) {
|
|
434
|
+
const file = path.resolve(
|
|
435
|
+
input.profileDir,
|
|
436
|
+
src.file.replace(/^~(?=$|\/)/, input.home).replace(/\{repo\}/g, input.repo),
|
|
437
|
+
);
|
|
438
|
+
// Readers take paths relative to a project root; from "/" an absolute path stays itself.
|
|
439
|
+
const value = resolveConfigPath(readStructured({ projectRoot: '/', stack: '', cache: new Map() }, file), src.jsonpath);
|
|
440
|
+
if (typeof value === 'string' && value.trim()) {
|
|
441
|
+
const shown = file.startsWith(input.home + path.sep) ? `~${file.slice(input.home.length)}` : file;
|
|
442
|
+
return { name: value.trim(), source: `${shown} ${src.jsonpath}` };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return { name: input.repo, source: 'repository name' };
|
|
446
|
+
}
|
|
447
|
+
|
|
387
448
|
/** `{host}`: `$VDX_HOST` when set (a machine label such as `lft`), else the short hostname. */
|
|
388
449
|
export function hostLabel(env: NodeJS.ProcessEnv = process.env): string {
|
|
389
450
|
const label = env[HOST_ENV_VAR]?.trim();
|
|
@@ -559,6 +620,7 @@ export const NOTHING_TO_RESUME_MS = 30_000;
|
|
|
559
620
|
export function renderPlan(plan: LaunchPlan, multiplexer: Multiplexer, running: AgentPane[]): string {
|
|
560
621
|
const lines = [
|
|
561
622
|
`vdx ai — ${plan.projectRoot}`,
|
|
623
|
+
` name: ${plan.project} (${plan.projectSource})`,
|
|
562
624
|
` profile: ${plan.profilePath ?? `none — built-in default (${FALLBACK_AGENTS.join(' or ')}, no flags, no tmux)`}`,
|
|
563
625
|
` agent: ${commandLine(plan.command, plan.args)}`,
|
|
564
626
|
` resume: ${plan.resumeArgs.length ? plan.resumeArgs.join(' ') : '—'}`,
|
|
@@ -693,6 +755,61 @@ function finish(ok: boolean, session: string, paneId: string, opts: AiOptions, d
|
|
|
693
755
|
return ok ? EXIT_OK : EXIT_LAUNCH_FAILED;
|
|
694
756
|
}
|
|
695
757
|
|
|
758
|
+
/** `vdx ai@<host>`: an ssh destination such as `m3` — never an option to ssh. */
|
|
759
|
+
export const REMOTE_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
760
|
+
|
|
761
|
+
/** A path under the local home as a shell word under the remote home; any other path as is. */
|
|
762
|
+
export function remotePathWord(abs: string, home: string): string {
|
|
763
|
+
const rel = path.relative(home, abs);
|
|
764
|
+
if (rel === '') return '"$HOME"';
|
|
765
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return shellQuote(abs);
|
|
766
|
+
return `"$HOME"/${shellQuote(rel)}`;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* The ssh argv for `vdx ai@<host>`: the same `vdx ai` on the host, in its own
|
|
771
|
+
* tmux there. The path goes first: an older vdx hands the token after a flag it
|
|
772
|
+
* does not know to that flag. A different vdx version there is named, not fatal.
|
|
773
|
+
*/
|
|
774
|
+
export function remoteAiArgs(input: {
|
|
775
|
+
host: string;
|
|
776
|
+
projectPath: string;
|
|
777
|
+
home: string;
|
|
778
|
+
version: string;
|
|
779
|
+
opts: AiOptions;
|
|
780
|
+
tty: boolean;
|
|
781
|
+
}): string[] {
|
|
782
|
+
const { host, opts, version } = input;
|
|
783
|
+
const flags = [
|
|
784
|
+
opts.fresh && '--new',
|
|
785
|
+
opts.restart && '--restart',
|
|
786
|
+
opts.detach && '--detach',
|
|
787
|
+
opts.dryRun && '--dry-run',
|
|
788
|
+
].filter((f): f is string => Boolean(f));
|
|
789
|
+
const v = shellQuote(version);
|
|
790
|
+
const script =
|
|
791
|
+
`v=$(vdx --version 2>/dev/null) || v='older than 0.13.1'; ` +
|
|
792
|
+
`[ "$v" = ${v} ] || echo "note: vdx on ${host} is $v, here "${v} >&2; ` +
|
|
793
|
+
`exec vdx ai ${[remotePathWord(input.projectPath, input.home), ...flags].join(' ')}`;
|
|
794
|
+
return [input.tty ? '-t' : '-T', host, script];
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, version: string): number {
|
|
798
|
+
const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
|
|
799
|
+
const tty = deps.interactive && !opts.detach && !opts.dryRun;
|
|
800
|
+
const args = remoteAiArgs({ host, projectPath, home: deps.home, version, opts, tty });
|
|
801
|
+
deps.log(`→ ${host}: vdx ai ${shellQuote(projectPath)}`);
|
|
802
|
+
const res = spawnSync('ssh', args, { stdio: 'inherit' });
|
|
803
|
+
if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
|
|
804
|
+
deps.log('vdx ai: `ssh` not found on PATH');
|
|
805
|
+
return 127;
|
|
806
|
+
}
|
|
807
|
+
if (opts.detach && res.status === EXIT_OK) {
|
|
808
|
+
deps.log(`attach from here: vdx ai@${host} ${shellQuote(projectPath)}`);
|
|
809
|
+
}
|
|
810
|
+
return res.status ?? 1;
|
|
811
|
+
}
|
|
812
|
+
|
|
696
813
|
export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
697
814
|
const { log, tmux } = deps;
|
|
698
815
|
|
|
@@ -722,6 +839,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
722
839
|
return EXIT_USAGE;
|
|
723
840
|
}
|
|
724
841
|
|
|
842
|
+
const named = resolveProjectName({
|
|
843
|
+
projectRoot,
|
|
844
|
+
repo: projectIdentity(projectRoot),
|
|
845
|
+
sources: environment.session?.project_names ?? [],
|
|
846
|
+
home: deps.home,
|
|
847
|
+
profileDir: profilePath ? path.dirname(profilePath) : deps.home,
|
|
848
|
+
});
|
|
849
|
+
|
|
725
850
|
let plan: LaunchPlan;
|
|
726
851
|
try {
|
|
727
852
|
plan = planLaunch({
|
|
@@ -729,7 +854,8 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
729
854
|
profilePath,
|
|
730
855
|
agent,
|
|
731
856
|
projectRoot,
|
|
732
|
-
project:
|
|
857
|
+
project: named.name,
|
|
858
|
+
projectSource: named.source,
|
|
733
859
|
host: hostLabel(deps.env),
|
|
734
860
|
});
|
|
735
861
|
} catch (e: any) {
|
package/src/doctor.ts
CHANGED
|
@@ -162,7 +162,7 @@ function checkNpmAuth(): CheckResult {
|
|
|
162
162
|
id: 'npm-auth',
|
|
163
163
|
label: 'npm auth',
|
|
164
164
|
status: 'warning',
|
|
165
|
-
message: 'not logged in — `vdx publish`
|
|
165
|
+
message: 'not logged in — `vdx publish` refuses at pre-flight (npm-auth)',
|
|
166
166
|
remedy: 'npm login',
|
|
167
167
|
};
|
|
168
168
|
}
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
reportDoctorJson,
|
|
20
20
|
reportDoctorAnsi,
|
|
21
21
|
} from './report.ts';
|
|
22
|
-
import { runDoctor, looksLikeProject } from './doctor.ts';
|
|
22
|
+
import { runDoctor, looksLikeProject, readCliVersion } from './doctor.ts';
|
|
23
23
|
import { planInit, writeInit, renderPlanSummary } from './init.ts';
|
|
24
24
|
import {
|
|
25
25
|
planPublish,
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
type PublishOptions,
|
|
30
30
|
} from './publish.ts';
|
|
31
31
|
import { resolveDefaultRubric } from './defaults.ts';
|
|
32
|
-
import { runAi, defaultDeps } from './ai.ts';
|
|
32
|
+
import { runAi, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
|
|
33
33
|
|
|
34
34
|
const DEFAULT_RUBRIC = resolveDefaultRubric();
|
|
35
35
|
|
|
@@ -41,7 +41,8 @@ function usage(): never {
|
|
|
41
41
|
vdx init [project_path] [--stack <id>] [--baseline <ref>] [--dry-run] [--force] (default: cwd)
|
|
42
42
|
vdx publish <patch|minor|major> [--dry-run] [--force]
|
|
43
43
|
vdx doctor [--format=ansi|markdown|json] [--json]
|
|
44
|
-
vdx ai
|
|
44
|
+
vdx ai[@host] [project_path] [--new] [--restart] [--detach] [--dry-run] (default: cwd; @host: over ssh, in tmux there)
|
|
45
|
+
vdx --version
|
|
45
46
|
`,
|
|
46
47
|
);
|
|
47
48
|
process.exit(1);
|
|
@@ -277,17 +278,28 @@ function cmdAi(opts: ParsedArgs): void {
|
|
|
277
278
|
bool('resume');
|
|
278
279
|
const detach = bool('detach');
|
|
279
280
|
const dryRun = bool('dry-run');
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
);
|
|
281
|
+
const options = { path: positionals[0] ?? '.', restart, fresh, detach, dryRun };
|
|
282
|
+
// `ai@m3`: the same command on another machine. Its own label runs here.
|
|
283
|
+
const host = opts.cmd.startsWith('ai@') ? opts.cmd.slice(3) : null;
|
|
284
|
+
if (host !== null) {
|
|
285
|
+
if (!REMOTE_HOST_RE.test(host)) {
|
|
286
|
+
process.stderr.write(`vdx ai@host: "${host}" is not a host name (an ssh destination such as m3)\n`);
|
|
287
|
+
process.exit(2);
|
|
288
|
+
}
|
|
289
|
+
if (host.toLowerCase() !== hostLabel().toLowerCase()) {
|
|
290
|
+
process.exit(runAiRemote(options, host, defaultDeps(), readCliVersion()));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
process.exit(runAi(options, defaultDeps()));
|
|
283
294
|
}
|
|
284
295
|
|
|
285
296
|
const parsed = parseArgs(process.argv);
|
|
286
|
-
if (parsed.cmd === '
|
|
297
|
+
if (parsed.cmd === '--version') process.stdout.write(readCliVersion() + '\n');
|
|
298
|
+
else if (parsed.cmd === 'audit') cmdAudit(parsed);
|
|
287
299
|
else if (parsed.cmd === 'init') cmdInit(parsed);
|
|
288
300
|
else if (parsed.cmd === 'publish') cmdPublish(parsed);
|
|
289
301
|
else if (parsed.cmd === 'doctor') cmdDoctor(parsed);
|
|
290
|
-
else if (parsed.cmd === 'ai') cmdAi(parsed);
|
|
302
|
+
else if (parsed.cmd === 'ai' || parsed.cmd.startsWith('ai@')) cmdAi(parsed);
|
|
291
303
|
else if ((LIFECYCLE_VERBS as readonly string[]).includes(parsed.cmd))
|
|
292
304
|
cmdRun(parsed.cmd as LifecycleVerb);
|
|
293
305
|
else usage();
|
package/src/init.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
|
|
13
13
|
export type Verb = (typeof STANDARD_VERBS)[number];
|
|
14
14
|
|
|
15
|
-
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.
|
|
15
|
+
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.5.0';
|
|
16
16
|
|
|
17
17
|
const VERB_ALIASES: Record<Verb, string[]> = {
|
|
18
18
|
up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
|
package/src/manifest.ts
CHANGED
|
@@ -5,6 +5,8 @@ import YAML from 'js-yaml';
|
|
|
5
5
|
|
|
6
6
|
export interface VdxManifest {
|
|
7
7
|
schema_version?: string;
|
|
8
|
+
/** Short name people use for the project (a session name, a sidebar); defaults to the repository name. */
|
|
9
|
+
name?: string;
|
|
8
10
|
baseline?: string;
|
|
9
11
|
stack?: string;
|
|
10
12
|
primary_language?: string;
|
package/src/publish.ts
CHANGED
|
@@ -79,12 +79,21 @@ function isGitWorkingTreeClean(projectRoot: string): { ok: boolean; detail: stri
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
/** Runs npm with these args and returns stdout; throws with `stderr` on a non-zero exit. */
|
|
83
|
+
export type NpmRunner = (args: string[]) => string;
|
|
84
|
+
|
|
85
|
+
// A caller's `npm run -s` passes `npm_config_loglevel=silent` down, and npm then
|
|
86
|
+
// fails with an empty stderr — no E401 or E404 left to tell the cases apart.
|
|
87
|
+
const runNpm: NpmRunner = (args) =>
|
|
88
|
+
execFileSync('npm', args, {
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
91
|
+
env: { ...process.env, npm_config_loglevel: 'error' },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
function fetchPublishedVersion(packageName: string, npm: NpmRunner): string | null {
|
|
83
95
|
try {
|
|
84
|
-
const out =
|
|
85
|
-
encoding: 'utf8',
|
|
86
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
87
|
-
});
|
|
96
|
+
const out = npm(['view', packageName, 'version']);
|
|
88
97
|
return out.trim() || null;
|
|
89
98
|
} catch (e: any) {
|
|
90
99
|
const stderr = String(e?.stderr ?? e?.message ?? '');
|
|
@@ -93,6 +102,54 @@ function fetchPublishedVersion(packageName: string): string | null {
|
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The registry `npm publish` sends this package to, when it is not npm's
|
|
107
|
+
* default: `publishConfig.registry`, else the scope's registry from npm config.
|
|
108
|
+
*/
|
|
109
|
+
export function publishRegistry(
|
|
110
|
+
pkg: { name?: string; publishConfig?: { registry?: string } },
|
|
111
|
+
npm: NpmRunner,
|
|
112
|
+
): string | null {
|
|
113
|
+
if (pkg.publishConfig?.registry) return pkg.publishConfig.registry;
|
|
114
|
+
const scope = pkg.name?.match(/^(@[^/]+)\//)?.[1];
|
|
115
|
+
if (!scope) return null;
|
|
116
|
+
try {
|
|
117
|
+
const value = npm(['config', 'get', `${scope}:registry`]).trim();
|
|
118
|
+
return value && value !== 'undefined' ? value : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Logged in where the package goes? Without it `npm publish` fails only after
|
|
126
|
+
* the bump and the pack, and with E404 on a scoped package, not E401 — an
|
|
127
|
+
* expired npm session reads as a missing package.
|
|
128
|
+
*/
|
|
129
|
+
export function checkNpmAuth(registry: string | null, npm: NpmRunner): PreflightCheck {
|
|
130
|
+
const where = registry ?? 'npm';
|
|
131
|
+
const login = registry ? `npm login --registry ${registry}` : 'npm login';
|
|
132
|
+
try {
|
|
133
|
+
const who = npm(registry ? ['whoami', '--registry', registry] : ['whoami']).trim();
|
|
134
|
+
if (who) return { name: 'npm-auth', ok: true, message: `OK (logged in to ${where} as ${who})` };
|
|
135
|
+
return { name: 'npm-auth', ok: false, message: `npm whoami answered nothing for ${where}. Run: ${login}` };
|
|
136
|
+
} catch (e: any) {
|
|
137
|
+
const stderr = String(e?.stderr ?? e?.message ?? '');
|
|
138
|
+
if (/E401|ENEEDAUTH/.test(stderr)) {
|
|
139
|
+
return {
|
|
140
|
+
name: 'npm-auth',
|
|
141
|
+
ok: false,
|
|
142
|
+
message: `not logged in to ${where} (the npm session may have expired). Run: ${login}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
name: 'npm-auth',
|
|
147
|
+
ok: false,
|
|
148
|
+
message: `npm whoami failed for ${where}: ${stderr.split('\n').find((l) => l.trim()) ?? stderr}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
96
153
|
function checkMiseOverride(projectRoot: string): boolean {
|
|
97
154
|
const miseToml = path.join(projectRoot, 'mise.toml');
|
|
98
155
|
if (!fs.existsSync(miseToml)) return false;
|
|
@@ -116,6 +173,7 @@ export function planPublish(
|
|
|
116
173
|
opts: PublishOptions,
|
|
117
174
|
audit: AuditResult,
|
|
118
175
|
ctx: Ctx,
|
|
176
|
+
npm: NpmRunner = runNpm,
|
|
119
177
|
): PublishPlan {
|
|
120
178
|
if (checkMiseOverride(opts.projectRoot)) {
|
|
121
179
|
return {
|
|
@@ -197,10 +255,12 @@ export function planPublish(
|
|
|
197
255
|
: `release-artifact at ${raLevel}, need >= L3. Add files[], main/exports, license in package.json.`,
|
|
198
256
|
});
|
|
199
257
|
|
|
258
|
+
preflight.push(checkNpmAuth(publishRegistry(pkg, npm), npm));
|
|
259
|
+
|
|
200
260
|
let published: string | null = null;
|
|
201
261
|
let registryError: string | null = null;
|
|
202
262
|
try {
|
|
203
|
-
published = fetchPublishedVersion(packageName);
|
|
263
|
+
published = fetchPublishedVersion(packageName, npm);
|
|
204
264
|
} catch (e: any) {
|
|
205
265
|
registryError = e?.message ?? String(e);
|
|
206
266
|
}
|