Skip to content

Commit 3785152

Browse files
committed
feat(cli): delegate to a project-local nativescript install
Invoking the (typically global) CLI inside a project that carries its own nativescript install now hands off to that copy - the project pins the version that actually runs, Angular/Nx style. The probe resolves nativescript/package.json from cwd, realpath-compares against the invoked copy so npm-linked installs are not mistaken for a different one, and any probe failure falls through to the invoked copy. The notice goes to stderr so scripts parsing stdout are unaffected. Opt out per invocation with --no-local-cli (stripped before option parsing) or NS_CLI_NO_LOCAL=1; NS_CLI_LOCAL_DELEGATED marks the handed-off process so a local copy never delegates again.
1 parent b52b8e0 commit 3785152

2 files changed

Lines changed: 187 additions & 0 deletions

File tree

bin/tns

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,63 @@ var path = require("path"),
1414

1515
require(path.join(pathToCommon, "verify-node-version")).verifyNodeVersion();
1616

17+
// Prefer a project-local CLI install when one is resolvable from the current
18+
// directory: the project pins its own nativescript version, and running the
19+
// invoked (typically global) copy against it means two CLI versions
20+
// disagreeing about one project. The probe must never break the CLI - any
21+
// failure falls through to the invoked copy. Opt out per invocation with
22+
// --no-local-cli or NS_CLI_NO_LOCAL=1; NS_CLI_LOCAL_DELEGATED marks the
23+
// handed-off process so the local copy never delegates again.
24+
var noLocalFlagIndex = process.argv.indexOf("--no-local-cli");
25+
if (noLocalFlagIndex !== -1) {
26+
process.argv.splice(noLocalFlagIndex, 1);
27+
}
28+
29+
if (
30+
!process.env.NS_CLI_LOCAL_DELEGATED &&
31+
!process.env.NS_CLI_NO_LOCAL &&
32+
noLocalFlagIndex === -1
33+
) {
34+
var localEntry = null;
35+
var localVersion = null;
36+
var localDir = null;
37+
try {
38+
var localPackageJsonPath = require.resolve("nativescript/package.json", {
39+
paths: [process.cwd()],
40+
});
41+
var ownPackageJsonPath = path.join(__dirname, "..", "package.json");
42+
// realpath both sides so an npm-linked or symlinked install of the
43+
// same copy is not mistaken for a different one.
44+
if (
45+
fs.realpathSync(localPackageJsonPath) !==
46+
fs.realpathSync(ownPackageJsonPath)
47+
) {
48+
localDir = path.dirname(localPackageJsonPath);
49+
var candidate = path.join(localDir, "bin", "tns");
50+
if (fs.existsSync(candidate)) {
51+
localEntry = candidate;
52+
localVersion = require(localPackageJsonPath).version;
53+
}
54+
}
55+
} catch (err) {
56+
// No local install resolvable from cwd - run the invoked copy.
57+
}
58+
59+
if (localEntry) {
60+
process.env.NS_CLI_LOCAL_DELEGATED = "1";
61+
// stderr, so scripts parsing stdout (e.g. `ns --version`) are unaffected.
62+
console.error(
63+
"Using the project-local nativescript@" +
64+
localVersion +
65+
" (" +
66+
localDir +
67+
").",
68+
);
69+
require(localEntry);
70+
return;
71+
}
72+
}
73+
1774
var pathToCliExecutable = path.join(pathToLib, "nativescript-cli.js");
1875

1976
require(pathToCliExecutable);

test/local-cli-delegation.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { assert } from "chai";
2+
import { spawnSync } from "child_process";
3+
import * as fs from "fs";
4+
import * as os from "os";
5+
import * as path from "path";
6+
7+
// Drives the real bin entry in a child process: delegation must happen before
8+
// any of lib/ loads, so it can only be observed from the outside.
9+
const repoRoot = path.join(__dirname, "..", "..");
10+
const cliEntry = path.join(repoRoot, "bin", "nativescript.js");
11+
const ownVersion = JSON.parse(
12+
fs.readFileSync(path.join(repoRoot, "package.json")).toString(),
13+
).version;
14+
15+
const LOCAL_MARKER = "LOCAL_CLI_RAN";
16+
17+
describe("project-local CLI delegation", () => {
18+
let projectDir: string;
19+
20+
const makeProject = (options?: {
21+
localCli?: boolean;
22+
symlinkToOwnCopy?: boolean;
23+
}): void => {
24+
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ns-localcli-"));
25+
fs.writeFileSync(
26+
path.join(projectDir, "package.json"),
27+
JSON.stringify({ name: "test-app", version: "1.0.0" }),
28+
);
29+
30+
if (options && options.symlinkToOwnCopy) {
31+
fs.mkdirSync(path.join(projectDir, "node_modules"), { recursive: true });
32+
fs.symlinkSync(
33+
repoRoot,
34+
path.join(projectDir, "node_modules", "nativescript"),
35+
"junction",
36+
);
37+
return;
38+
}
39+
40+
if (options && options.localCli) {
41+
const packageDir = path.join(projectDir, "node_modules", "nativescript");
42+
fs.mkdirSync(path.join(packageDir, "bin"), { recursive: true });
43+
fs.writeFileSync(
44+
path.join(packageDir, "package.json"),
45+
JSON.stringify({ name: "nativescript", version: "99.0.0-local" }),
46+
);
47+
fs.writeFileSync(
48+
path.join(packageDir, "bin", "tns"),
49+
`console.log("${LOCAL_MARKER} delegated=" + process.env.NS_CLI_LOCAL_DELEGATED);`,
50+
);
51+
}
52+
};
53+
54+
afterEach(() => {
55+
fs.rmSync(projectDir, { recursive: true, force: true });
56+
});
57+
58+
const runCli = (
59+
args: string[] = ["--version"],
60+
envOverrides: { [key: string]: string } = {},
61+
) => {
62+
const env: any = { ...process.env, ...envOverrides };
63+
delete env.NS_CLI_LOCAL_DELEGATED;
64+
delete env.NS_CLI_NO_LOCAL;
65+
for (const key of Object.keys(envOverrides)) {
66+
env[key] = envOverrides[key];
67+
}
68+
return spawnSync(process.execPath, [cliEntry, ...args], {
69+
cwd: projectDir,
70+
encoding: "utf8",
71+
env,
72+
});
73+
};
74+
75+
it("hands off to a project-local install, marking the delegated process", () => {
76+
makeProject({ localCli: true });
77+
78+
const result = runCli();
79+
80+
assert.include(result.stdout, `${LOCAL_MARKER} delegated=1`);
81+
assert.include(result.stderr, "project-local nativescript@99.0.0-local");
82+
assert.notInclude(result.stdout, ownVersion);
83+
});
84+
85+
it("runs the invoked copy when the project has no local install", () => {
86+
makeProject();
87+
88+
const result = runCli();
89+
90+
assert.include(result.stdout, ownVersion);
91+
assert.notInclude(result.stdout, LOCAL_MARKER);
92+
assert.notInclude(result.stderr, "project-local");
93+
});
94+
95+
it("does not delegate to a symlink of the same copy (npm link)", () => {
96+
makeProject({ symlinkToOwnCopy: true });
97+
98+
const result = runCli();
99+
100+
assert.include(result.stdout, ownVersion);
101+
assert.notInclude(result.stderr, "project-local");
102+
});
103+
104+
it("--no-local-cli opts out and is stripped before option parsing", () => {
105+
makeProject({ localCli: true });
106+
107+
const result = runCli(["--version", "--no-local-cli"]);
108+
109+
assert.include(result.stdout, ownVersion);
110+
assert.notInclude(result.stdout, LOCAL_MARKER);
111+
});
112+
113+
it("NS_CLI_NO_LOCAL opts out", () => {
114+
makeProject({ localCli: true });
115+
116+
const result = runCli(["--version"], { NS_CLI_NO_LOCAL: "1" });
117+
118+
assert.include(result.stdout, ownVersion);
119+
assert.notInclude(result.stdout, LOCAL_MARKER);
120+
});
121+
122+
it("a delegated process never delegates again", () => {
123+
makeProject({ localCli: true });
124+
125+
const result = runCli(["--version"], { NS_CLI_LOCAL_DELEGATED: "1" });
126+
127+
assert.include(result.stdout, ownVersion);
128+
assert.notInclude(result.stdout, LOCAL_MARKER);
129+
});
130+
});

0 commit comments

Comments
 (0)